Record standby subscription events with signed durable receipts

This commit is contained in:
root
2026-10-21 09:57:46 +08:00
parent 9faae09851
commit d6eb25a0f6
13 changed files with 860 additions and 4 deletions
@@ -0,0 +1,33 @@
package com.lion.lionwebsite.Controller;
import com.lion.lionwebsite.Service.SubscriptionEventService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.Map;
@RestController
public class SubscriptionEventController {
private final SubscriptionEventService events;
public SubscriptionEventController(SubscriptionEventService events) { this.events = events; }
@PostMapping(value = "/internal/subscription-events", produces = "application/json")
public ResponseEntity<Map<String, Object>> ingest(HttpServletRequest request) throws IOException {
// Bound actual bytes even for chunked requests and dishonest Content-Length headers.
byte[] body = request.getInputStream().readNBytes(SubscriptionEventService.MAX_BODY + 1);
if (body.length > SubscriptionEventService.MAX_BODY)
return ResponseEntity.badRequest().body(Map.of("error", "invalid subscription events"));
if (!events.verify(body, request.getHeader("X-Subscription-Timestamp"), request.getHeader("X-Subscription-Signature")))
return ResponseEntity.status(403).body(Map.of("error", "authentication failed"));
try {
return ResponseEntity.ok(events.ingest(events.parseBatch(body)));
} catch (IllegalArgumentException invalid) {
return ResponseEntity.badRequest().body(Map.of("error", "invalid subscription events"));
}
// All infrastructure failures propagate as 5xx; never acknowledge an uncommitted batch.
}
}
@@ -80,7 +80,7 @@ public interface SubMapper {
@Select("select count(user) from sub_update_record where user_id=(select id from User where username=#{user}) or (user_id is null and user=#{user})")
Integer selectUpdateRecordCount(String user);
@Select("select min(id) from sub_update_record where user_id=(select id from User where username=#{user}) or (user_id is null and user=#{user})")
@Select("select id from sub_update_record where user_id=(select id from User where username=#{user}) or (user_id is null and user=#{user}) order by time asc,id asc limit 1")
Integer selectMinUpdateRecordId(String user);
/** 后台补齐归属地:只更新该用户最近一条记录,避免覆盖更早的历史记录。 */
@@ -0,0 +1,31 @@
package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.SubscriptionEvent;
import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionEventBinding;
import org.apache.ibatis.annotations.*;
@Mapper
public interface SubscriptionEventMapper {
@Select("select fingerprint from subscription_event_receipt where event_id=#{eventId}")
String fingerprint(String eventId);
@Insert("insert into subscription_event_receipt(event_id,key_hash,client,occurred_at,source,ingested_at,fingerprint,outcome) values(#{event.eventId},#{event.keyHash},#{event.client},#{event.occurredAt},#{source},#{ingestedAt},#{fingerprint},#{outcome})")
void insertReceipt(@Param("event") SubscriptionEvent event, @Param("source") String source,
@Param("ingestedAt") long ingestedAt, @Param("fingerprint") String fingerprint,
@Param("outcome") String outcome);
@Select("select key_hash as keyHash,user_id as userId,user_name as userName from subscription_event_binding where key_hash=#{hash}")
SubscriptionEventBinding binding(String hash);
@Insert("insert into subscription_event_binding(key_hash,user_id,user_name,first_seen_at,last_seen_at) values(#{hash},#{userId},#{userName},#{now},#{now}) on conflict(key_hash) do update set user_name=excluded.user_name,last_seen_at=excluded.last_seen_at where subscription_event_binding.user_id IS excluded.user_id")
void rememberBinding(@Param("hash") String hash, @Param("userId") Integer userId,
@Param("userName") String userName, @Param("now") long now);
@Insert("insert into sub_update_record(user,ip,UA,time,location,user_id) values(#{user},#{ip},#{UA},#{time},#{location},#{userId})")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insertRecord(SubUpdateRecord record);
@Delete("delete from sub_update_record where (user_id=#{userId} or (user_id is null and user=#{userName})) and id not in (select id from sub_update_record where user_id=#{userId} or (user_id is null and user=#{userName}) order by time desc,id desc limit 10)")
void pruneRecords(@Param("userId") Integer userId, @Param("userName") String userName);
}
@@ -0,0 +1,16 @@
package com.lion.lionwebsite.Domain;
import lombok.AllArgsConstructor;
import lombok.Data;
/** Validated, authenticated event. Never contains the public subscription key. */
@Data
@AllArgsConstructor
public class SubscriptionEvent {
private String eventId;
private String keyHash;
private String client;
private String clientIp;
private String userAgent;
private long occurredAt;
}
@@ -0,0 +1,10 @@
package com.lion.lionwebsite.Domain;
import lombok.Data;
@Data
public class SubscriptionEventBinding {
private String keyHash;
private Integer userId;
private String userName;
}
@@ -5,6 +5,7 @@ import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionEvent;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload;
@@ -47,6 +48,9 @@ import java.util.function.Supplier;
@Qualifier("mainTransactionTemplate")
TransactionTemplate transactions;
@Autowired(required = false)
SubscriptionEventService subscriptionEvents;
/**
* 归属地查询是同步外呼(最长十几秒),且失败只影响记录里的一列展示。
* 若放在订阅分发路径上,用户取一次订阅就得先等它完成,因此改为后台补齐。
@@ -188,6 +192,7 @@ import java.util.function.Supplier;
String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8);
rememberEventBindings();
subMapper.updateSubBindKey(user, key);
subMapper.deleteSubUpdateRecord(user);
remoteService.requestSubscriptionSync();
@@ -239,8 +244,9 @@ import java.util.function.Supplier;
}
String ip = resolveClientIp(request);
String ua = request.getHeader("User-Agent");
if (ua == null) return;
recordUpdate(subBind, ip, ua);
SubscriptionEvent event = subscriptionEvents == null ? null : subscriptionEvents.requestEvent(request, key, client);
if (ua == null && event == null) return;
if (event == null) recordUpdate(subBind, ip, ua);
if (!"v2".equals(client) && !"cat".equals(client)) {
sendStatus(response, HttpServletResponse.SC_BAD_REQUEST, "client error");
return;
@@ -250,6 +256,8 @@ import java.util.function.Supplier;
sendStatus(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "subscription unavailable");
return;
}
if (event != null && !"HEAD".equalsIgnoreCase(request.getMethod()))
subscriptionEvents.recordPrimary(event, subBind);
FileDownload.export(request, response, path.toString());
}
@@ -329,12 +337,17 @@ import java.util.function.Supplier;
}
private String deleteSubBindUnlocked(String user) {
rememberEventBindings();
subMapper.deleteSubBind(user);
subMapper.deleteSubUpdateRecord(user);
remoteService.requestSubscriptionSync();
return Response._success("删除成功");
}
private void rememberEventBindings() {
if (subscriptionEvents != null) subscriptionEvents.rememberBindings(subMapper.selectAllSubBind());
}
private String withWriteLock(Supplier<String> action) {
Lock stateLock = stateCoordinator.writeLock();
stateLock.lock();
@@ -0,0 +1,222 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Dao.normal.SubscriptionEventMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.*;
import com.lion.lionwebsite.Util.CustomUtil;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import tools.jackson.databind.JsonNode;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.Lock;
@Service
public class SubscriptionEventService {
public static final int MAX_BODY = 256 * 1024;
private final SubscriptionEventMapper events;
private final SubMapper subscriptions;
private final UserMapper users;
private final TransactionTemplate transactions;
private final SubscriptionStateCoordinator coordinator;
@Value("${subscription.standby.sync-secret:}") String syncSecret;
Clock clock = Clock.systemUTC();
private final ExecutorService locations = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(128), runnable -> {
Thread thread = new Thread(runnable, "subscription-event-location");
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy());
public SubscriptionEventService(SubscriptionEventMapper events, SubMapper subscriptions, UserMapper users,
@Qualifier("mainTransactionTemplate") TransactionTemplate transactions,
SubscriptionStateCoordinator coordinator) {
this.events = events;
this.subscriptions = subscriptions;
this.users = users;
this.transactions = transactions;
this.coordinator = coordinator;
}
/** Auth failure deliberately discloses neither signature nor payload. */
public boolean verify(byte[] body, String timestamp, String signature) {
if (body == null || body.length > MAX_BODY || syncSecret == null || syncSecret.isBlank()
|| timestamp == null || !timestamp.matches("[0-9]{1,12}")
|| signature == null || !signature.matches("[0-9a-fA-F]{64}")) return false;
try {
long seconds = Long.parseLong(timestamp);
if (Math.abs(clock.instant().getEpochSecond() - seconds) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(syncSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(("v1\n" + timestamp + "\n" + sha256(body)).getBytes(StandardCharsets.UTF_8));
return MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature));
} catch (Exception invalid) { return false; }
}
/** Invalid or absent signed headers fall back to the ordinary logging path. */
public SubscriptionEvent requestEvent(HttpServletRequest request, String key, String client) {
String encoded = request.getHeader("X-Subscription-Event");
if (encoded == null || encoded.length() > 8192 || !encoded.matches("[A-Za-z0-9_-]+")) return null;
try {
byte[] body = Base64.getUrlDecoder().decode(encoded);
if (!verify(body, request.getHeader("X-Subscription-Timestamp"), request.getHeader("X-Subscription-Signature"))) return null;
SubscriptionEvent event = parse(CustomUtil.objectMapper.readTree(body));
String ua = Objects.requireNonNullElse(request.getHeader("User-Agent"), "");
if (!event.getKeyHash().equals(sha256(key.getBytes(StandardCharsets.UTF_8)))
|| !event.getClient().equals(client) || !event.getUserAgent().equals(ua)) return null;
return event;
} catch (Exception invalid) { return null; }
}
public List<SubscriptionEvent> parseBatch(byte[] body) {
try {
if (body == null || body.length > MAX_BODY) throw new IllegalArgumentException();
JsonNode root = CustomUtil.objectMapper.readTree(body);
JsonNode list = root.get("events");
if (!root.isObject() || list == null || !list.isArray() || list.size() > 100) throw new IllegalArgumentException();
List<SubscriptionEvent> result = new ArrayList<>();
for (JsonNode item : list) result.add(parse(item));
return result;
} catch (Exception invalid) { throw new IllegalArgumentException("invalid subscription events"); }
}
SubscriptionEvent parse(JsonNode node) {
if (node == null || !node.isObject()) throw new IllegalArgumentException();
String id = text(node, "eventId", 36);
if (!UUID.fromString(id).toString().equals(id)) throw new IllegalArgumentException();
String hash = text(node, "keyHash", 64);
if (!hash.matches("[0-9a-f]{64}")) throw new IllegalArgumentException();
String client = text(node, "client", 3);
if (!client.equals("v2") && !client.equals("cat")) throw new IllegalArgumentException();
String ip = text(node, "clientIp", 64);
// Literal IPv4/IPv6 only: no host names, URL fragments or query injection into geolocation.
if (ip.isEmpty() || !ip.matches("[0-9A-Fa-f:.]+")) throw new IllegalArgumentException();
String ua = text(node, "userAgent", 2048);
if (ua.chars().anyMatch(c -> c < 32 || c == 127)) throw new IllegalArgumentException();
JsonNode time = node.get("occurredAt");
if (time == null || !time.isIntegralNumber() || !time.canConvertToLong()) throw new IllegalArgumentException();
long occurred = time.longValue();
if (occurred < 0 || occurred > clock.millis() + 300_000) throw new IllegalArgumentException();
return new SubscriptionEvent(id, hash, client, ip, ua, occurred);
}
private static String text(JsonNode node, String field, int max) {
JsonNode value = node.get(field);
if (value == null || !value.isString() || value.stringValue().length() > max) throw new IllegalArgumentException();
return value.stringValue();
}
public Map<String, Object> ingest(List<SubscriptionEvent> batch) {
Lock lock = coordinator.readLock();
lock.lock();
try {
// Return acknowledgements only after execute has returned and the entire transaction committed.
List<String> acknowledged = transactions.execute(status -> {
List<String> ids = new ArrayList<>();
for (SubscriptionEvent event : batch) if (persist(event, "standby", null)) ids.add(event.getEventId());
return ids;
});
return Map.of("acknowledged", Objects.requireNonNull(acknowledged));
} finally { lock.unlock(); }
}
public void recordPrimary(SubscriptionEvent event, SubBind binding) {
transactions.executeWithoutResult(status -> {
if (!persist(event, "primary", binding)) throw new IllegalStateException("subscription user unavailable");
});
}
private boolean persist(SubscriptionEvent event, String source, SubBind current) {
String fingerprint = sha256((event.getEventId() + "\n" + event.getKeyHash() + "\n" + event.getClient()
+ "\n" + event.getOccurredAt() + "\n" + event.getClientIp() + "\n" + event.getUserAgent()).getBytes(StandardCharsets.UTF_8));
String old = events.fingerprint(event.getEventId());
if (old != null) {
if (!old.equals(fingerprint)) throw new IllegalArgumentException("event ID conflict");
return true;
}
Integer userId = current == null ? null : current.getUserId();
String userName = current == null ? null : current.getUser();
if (current == null) {
boolean found = false;
for (SubBind binding : subscriptions.selectAllSubBind()) {
if (sha256(binding.getKey().getBytes(StandardCharsets.UTF_8)).equals(event.getKeyHash())) {
userId = binding.getUserId();
userName = binding.getUser();
found = true;
break;
}
}
if (!found) {
SubscriptionEventBinding binding = events.binding(event.getKeyHash());
if (binding == null) return false;
userId = binding.getUserId();
userName = binding.getUserName();
}
}
if (userId != null) {
User user = users.selectUserById(userId);
if (user == null) {
events.insertReceipt(event, source, clock.millis(), fingerprint, "user_deleted");
return true;
}
userName = user.getUsername();
}
// NULL user_id is a valid legacy subscription label, not a missing/deleted User.
// Keep it NULL even if someone later creates a User with the same label.
if (userName == null || userName.isBlank()) return false;
events.rememberBinding(event.getKeyHash(), userId, userName, clock.millis());
SubUpdateRecord record = new SubUpdateRecord(0, userName, event.getClientIp(), event.getUserAgent(),
new Date(event.getOccurredAt()), "unknown", userId);
events.insertRecord(record);
events.pruneRecords(userId, userName);
events.insertReceipt(event, source, clock.millis(), fingerprint, "recorded");
// Never perform network calls before commit. A dropped/rolled-back record has no enrichment task.
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(
new org.springframework.transaction.support.TransactionSynchronization() {
@Override public void afterCommit() { enrich(record.getId(), event.getClientIp()); }
});
return true;
}
public void rememberBindings(Collection<SubBind> bindings) {
for (SubBind binding : bindings) {
Integer userId = binding.getUserId();
String name = binding.getUser();
if (userId != null) {
User user = users.selectUserById(userId);
if (user != null) name = user.getUsername();
}
if (name != null && !name.isBlank()) events.rememberBinding(
sha256(binding.getKey().getBytes(StandardCharsets.UTF_8)), userId, name, clock.millis());
}
}
private void enrich(int recordId, String ip) {
if (recordId <= 0) return;
try { locations.execute(() -> {
String location = SubService.resolveLocation(ip);
if (location != null && !location.isBlank() && !location.equals("unknown")) {
try { subscriptions.updateSubUpdateRecordLocation(recordId, location); }
catch (Exception ignored) { /* Optional display enrichment; no request failure or sensitive log. */ }
}
}); } catch (RejectedExecutionException ignored) { }
}
public static String sha256(byte[] body) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)); }
catch (Exception failure) { throw new IllegalStateException("SHA-256 unavailable"); }
}
@PreDestroy void close() { locations.shutdownNow(); }
}
@@ -9,6 +9,7 @@ import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.SubscriptionCacheStore;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
@@ -29,6 +30,9 @@ public class SubscriptionStandbySnapshotService {
private final SubscriptionStateCoordinator stateCoordinator;
private final ObjectMapper objectMapper = CustomUtil.objectMapper;
@Autowired(required = false)
SubscriptionEventService subscriptionEvents;
@Value("${subscription.standby.sync-secret:}")
String syncSecret;
@@ -78,7 +82,9 @@ public class SubscriptionStandbySnapshotService {
List<SubscriptionAccountSnapshot> accounts = new ArrayList<>(accountMap.values());
accounts.sort(Comparator.comparing(SubscriptionAccountSnapshot::getAccountId));
List<SubscriptionBindingSnapshot> bindings = new ArrayList<>();
for (SubBind bind : subMapper.selectAllSubBind()) {
List<SubBind> currentBindings = subMapper.selectAllSubBind();
if (subscriptionEvents != null) subscriptionEvents.rememberBindings(currentBindings);
for (SubBind bind : currentBindings) {
if (bind.getSubscriptionAccountId() == null || !accountMap.containsKey(bind.getSubscriptionAccountId()))
continue;
SubscriptionBindingSnapshot snapshot = new SubscriptionBindingSnapshot();
@@ -231,6 +231,22 @@ class MyBatisNativeConfigurationTest {
"同名 XML 映射文件应登记");
}
@Test
void subscriptionEventMapperRegistersEventAndBindingReflection() {
var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor();
var factory = new org.springframework.beans.factory.support.DefaultListableBeanFactory();
var definition = new RootBeanDefinition(MapperFactoryBean.class);
definition.getPropertyValues().add("mapperInterface", com.lion.lionwebsite.Dao.normal.SubscriptionEventMapper.class);
factory.registerBeanDefinition("subscriptionEventMapper", definition);
var hints = new RuntimeHints();
var contribution = processor.processAheadOfTime(factory);
assertNotNull(contribution);
contribution.applyTo(new StubGenerationContext(hints), new NoOpInitializationCode());
assertTrue(RuntimeHintsPredicates.reflection().onType(com.lion.lionwebsite.Domain.SubscriptionEvent.class).test(hints));
assertTrue(RuntimeHintsPredicates.reflection().onType(com.lion.lionwebsite.Domain.SubscriptionEventBinding.class).test(hints));
assertTrue(RuntimeHintsPredicates.proxies().forInterfaces(com.lion.lionwebsite.Dao.normal.SubscriptionEventMapper.class).test(hints));
}
/** applyTo 只用 runtimeHints,初始化代码侧给一个记录型空实现即可。 */
private static final class NoOpInitializationCode
implements org.springframework.beans.factory.aot.BeanFactoryInitializationCode {
@@ -0,0 +1,317 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Controller.SubscriptionEventController;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Dao.normal.SubscriptionEventMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.SubscriptionEvent;
import com.lion.lionwebsite.Util.CustomUtil;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.nio.file.Files;
import static org.mockito.Mockito.*;
import org.springframework.transaction.support.TransactionTemplate;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** Real SQLite/MyBatis/Spring transactions: these assertions cover committed database state. */
class SubscriptionEventIntegrationTest {
@TempDir Path directory;
private JdbcTemplate db;
private SubscriptionEventService service;
private SubMapper subscriptions;
private static final long NOW = 1_792_555_200_000L;
private static final String SECRET = "integration-test-only-secret";
private static final String KEY = "example-key";
@BeforeEach void setUp() throws Exception {
var source = new DriverManagerDataSource("jdbc:sqlite:" + directory.resolve("events.db") + "?busy_timeout=10000");
db = new JdbcTemplate(source);
db.execute("create table User(id integer primary key,username text,AuthCode text,isEnable integer,lastAccessTime text)");
db.execute("create table subscription_account(id integer primary key,name text,enabled integer,filter_high_multiplier integer)");
db.execute("create table sub_bind(key text primary key,user text,user_id integer,subscription_account_id integer)");
db.execute("create table sub_update_record(id integer primary key autoincrement,user text,ip text,UA text,time integer,location text,user_id integer)");
db.execute("create table subscription_event_receipt(event_id text primary key,key_hash text,client text,occurred_at integer,source text,ingested_at integer,fingerprint text,outcome text)");
db.execute("create table subscription_event_binding(key_hash text primary key,user_id integer,user_name text,first_seen_at integer,last_seen_at integer)");
db.update("insert into User(id,username,isEnable) values(5,'alice',1)");
db.update("insert into subscription_account values(1,'account',1,1)");
db.update("insert into sub_bind values(?,'alice',5,1)", KEY);
var bean = new SqlSessionFactoryBean();
bean.setDataSource(source);
var factory = bean.getObject();
factory.getConfiguration().addMapper(SubMapper.class);
factory.getConfiguration().addMapper(UserMapper.class);
factory.getConfiguration().addMapper(SubscriptionEventMapper.class);
var session = new SqlSessionTemplate(factory);
subscriptions = session.getMapper(SubMapper.class);
service = new SubscriptionEventService(session.getMapper(SubscriptionEventMapper.class), subscriptions,
session.getMapper(UserMapper.class), new TransactionTemplate(new DataSourceTransactionManager(source)),
new SubscriptionStateCoordinator());
service.syncSecret = SECRET;
service.clock = Clock.fixed(Instant.ofEpochMilli(NOW), ZoneOffset.UTC);
service.close(); // Reject optional geolocation work; never make network calls from these tests.
}
@Test void hmacRejectsInvalidExpiredModifiedAndOversizedInputs() throws Exception {
byte[] body = batch(event(NOW - 60_000));
String timestamp = Long.toString(NOW / 1000);
assertTrue(service.verify(body, timestamp, sign(body, timestamp)));
assertFalse(service.verify(body, timestamp, "0".repeat(64)));
assertFalse(service.verify("changed".getBytes(StandardCharsets.UTF_8), timestamp, sign(body, timestamp)));
String expired = Long.toString(NOW / 1000 - 301);
assertFalse(service.verify(body, expired, sign(body, expired)));
assertFalse(service.verify(body, "9".repeat(1024), "0".repeat(64)));
assertFalse(service.verify(new byte[SubscriptionEventService.MAX_BODY + 1], timestamp, "0".repeat(64)));
}
@Test void primaryAndStandbySameEventProduceOneRecordAndPreserveOriginalMetadata() {
SubscriptionEvent event = event(NOW - 86_400_000);
service.recordPrimary(event, subscriptions.selectSubBind(KEY));
assertEquals(List.of(event.getEventId()), service.ingest(List.of(event)).get("acknowledged"));
assertEquals(1, count("sub_update_record"));
assertEquals(1, count("subscription_event_receipt"));
assertEquals("primary", db.queryForObject("select source from subscription_event_receipt", String.class));
Map<String, Object> row = db.queryForMap("select * from sub_update_record");
assertEquals(event.getOccurredAt(), ((Number) row.get("time")).longValue());
assertEquals("2001:db8::7", row.get("ip"));
assertEquals("client/1.2", row.get("UA"));
assertEquals(5, ((Number) row.get("user_id")).intValue());
}
@Test void keyRotationAndRenameRetainStableIdentity() {
service.rememberBindings(subscriptions.selectAllSubBind());
db.update("update sub_bind set key='rotated-key'");
db.update("update User set username='renamed' where id=5");
SubscriptionEvent old = event(NOW - 30_000);
assertEquals(List.of(old.getEventId()), service.ingest(List.of(old)).get("acknowledged"));
assertEquals("renamed", db.queryForObject("select user from sub_update_record", String.class));
assertEquals(5, db.queryForObject("select user_id from sub_update_record", Integer.class));
assertEquals(SubscriptionEventService.sha256(KEY.getBytes(StandardCharsets.UTF_8)),
db.queryForObject("select key_hash from subscription_event_binding", String.class));
}
@Test void legacyLabelWithoutUserHasPrimaryStandbyReplayAndRotatedKeyHistory() {
db.update("update sub_bind set user='legacy-label',user_id=NULL");
db.update("delete from User");
SubscriptionEvent primary = event(NOW - 10_000);
service.recordPrimary(primary, subscriptions.selectSubBind(KEY));
assertEquals(List.of(primary.getEventId()), service.ingest(List.of(primary)).get("acknowledged"));
assertEquals(1, count("sub_update_record"));
assertEquals("legacy-label", db.queryForObject("select user from sub_update_record", String.class));
assertNull(db.queryForObject("select user_id from sub_update_record", Integer.class));
service.rememberBindings(subscriptions.selectAllSubBind());
db.update("update sub_bind set key='rotated-legacy-key'");
SubscriptionEvent offline = event(NOW - 5_000);
assertEquals(List.of(offline.getEventId()), service.ingest(List.of(offline)).get("acknowledged"));
assertEquals(2, count("sub_update_record"));
assertEquals(0, count("User"));
assertEquals(2, db.queryForObject("select count(*) from sub_update_record where user='legacy-label' and user_id is null", Integer.class));
for (int i = 0; i < 12; i++) service.ingest(List.of(event(NOW - i)));
assertEquals(10, count("sub_update_record"));
assertEquals(14, count("subscription_event_receipt"));
service.ingest(List.of(primary));
assertEquals(10, count("sub_update_record"));
}
@Test void legacyLabelDoesNotAdoptNewSameNamedUser() {
db.update("update sub_bind set user='legacy-label',user_id=NULL");
service.rememberBindings(subscriptions.selectAllSubBind());
db.update("delete from sub_bind");
db.update("insert into User(id,username,isEnable) values(8,'legacy-label',1)");
SubscriptionEvent event = event(NOW);
assertEquals(List.of(event.getEventId()), service.ingest(List.of(event)).get("acknowledged"));
assertNull(db.queryForObject("select user_id from sub_update_record", Integer.class));
}
@Test void unknownBindingIsNotAcknowledgedAndDeletedUserIsTerminal() {
SubscriptionEvent unknown = event(NOW);
unknown.setKeyHash("0".repeat(64));
assertEquals(List.of(), service.ingest(List.of(unknown)).get("acknowledged"));
assertEquals(0, count("subscription_event_receipt"));
service.rememberBindings(subscriptions.selectAllSubBind());
db.update("delete from sub_bind");
db.update("delete from User");
SubscriptionEvent deleted = event(NOW - 1_000);
assertEquals(List.of(deleted.getEventId()), service.ingest(List.of(deleted)).get("acknowledged"));
assertEquals("user_deleted", db.queryForObject("select outcome from subscription_event_receipt", String.class));
assertEquals(0, count("sub_update_record"));
}
@Test void transactionFailureRollsBackWholeBatchAndControllerNeverAcknowledges() throws Exception {
db.execute("create trigger fail_receipt before insert on subscription_event_receipt begin select raise(ABORT,'test rollback'); end");
SubscriptionEvent event = event(NOW);
assertThrows(RuntimeException.class, () -> service.ingest(List.of(event)));
assertEquals(0, count("sub_update_record"));
assertEquals(0, count("subscription_event_receipt"));
var controller = new SubscriptionEventController(service);
assertThrows(RuntimeException.class, () -> controller.ingest(signedPost(batch(event))));
assertEquals(0, count("sub_update_record"));
}
@Test void pruningUsesEventTimeAndReceiptsSurviveVisibleRecordDeletion() {
SubscriptionEvent oldest = event(NOW - 100_000);
service.ingest(List.of(oldest));
List<SubscriptionEvent> newer = new ArrayList<>();
for (int i = 0; i < 12; i++) newer.add(event(NOW - i * 1_000));
service.ingest(newer);
assertEquals(10, count("sub_update_record"));
assertEquals(13, count("subscription_event_receipt"));
assertEquals(NOW - 9_000, db.queryForObject("select min(time) from sub_update_record", Long.class));
assertEquals(List.of(oldest.getEventId()), service.ingest(List.of(oldest)).get("acknowledged"));
assertEquals(10, count("sub_update_record"));
db.update("delete from sub_update_record");
service.recordPrimary(newer.getFirst(), subscriptions.selectSubBind(KEY));
assertEquals(0, count("sub_update_record"));
assertEquals(13, count("subscription_event_receipt"));
}
@Test void signedGetVerifiesKeyClientUaAndUsesAuthenticatedIp() throws Exception {
SubscriptionEvent event = event(NOW);
byte[] body = CustomUtil.objectMapper.writeValueAsBytes(asMap(event));
var request = new MockHttpServletRequest("GET", "/sub/cat/" + KEY);
request.setRemoteAddr("127.0.0.1");
request.addHeader("X-Forwarded-For", "198.51.100.123");
request.addHeader("User-Agent", event.getUserAgent());
request.addHeader("X-Subscription-Event", Base64.getUrlEncoder().withoutPadding().encodeToString(body));
String timestamp = Long.toString(NOW / 1000);
request.addHeader("X-Subscription-Timestamp", timestamp);
request.addHeader("X-Subscription-Signature", sign(body, timestamp));
SubscriptionEvent parsed = service.requestEvent(request, KEY, "cat");
assertNotNull(parsed);
assertEquals("2001:db8::7", parsed.getClientIp());
assertNull(service.requestEvent(request, "wrong-key", "cat"));
assertNull(service.requestEvent(request, KEY, "v2"));
request.removeHeader("User-Agent");
request.addHeader("User-Agent", "different-client");
assertNull(service.requestEvent(request, KEY, "cat"));
}
@Test void signedHeadAndUnavailableCacheDoNotWriteAndGetReplayWritesOnce() throws Exception {
var refresh = mock(SubscriptionRefreshService.class);
var sub = new SubService(subscriptions, mock(UserMapper.class), refresh, mock(RemoteService.class),
new SubscriptionStateCoordinator());
sub.subscriptionEvents = service;
sub.locationExecutor = runnable -> { };
Path cache = directory.resolve("subscription.txt");
when(refresh.cachedPath(1, "cat")).thenReturn(cache);
SubscriptionEvent event = event(NOW);
var unavailable = new MockHttpServletResponse();
sub.updateSub(unavailable, signedGet(event, "GET"), "cat", KEY);
assertEquals(503, unavailable.getStatus());
assertEquals(0, count("sub_update_record"));
Files.writeString(cache, "subscription-content");
var head = new MockHttpServletResponse();
sub.updateSub(head, signedGet(event, "HEAD"), "cat", KEY);
assertEquals(200, head.getStatus());
assertEquals(0, head.getContentAsByteArray().length);
assertEquals(0, count("sub_update_record"));
assertEquals(0, count("subscription_event_receipt"));
for (int i = 0; i < 2; i++) {
var get = new MockHttpServletResponse();
sub.updateSub(get, signedGet(event, "GET"), "cat", KEY);
assertEquals(200, get.getStatus());
assertEquals("subscription-content", get.getContentAsString());
}
assertEquals(1, count("sub_update_record"));
assertEquals(1, count("subscription_event_receipt"));
assertEquals("2001:db8::7", db.queryForObject("select ip from sub_update_record", String.class));
sub.shutdownLocationResolver();
}
@Test void invalidSignedGetStillUsesOrdinaryLogging() throws Exception {
var refresh = mock(SubscriptionRefreshService.class);
var sub = new SubService(subscriptions, mock(UserMapper.class), refresh, mock(RemoteService.class),
new SubscriptionStateCoordinator());
sub.subscriptionEvents = service;
sub.locationExecutor = runnable -> { };
Path cache = directory.resolve("subscription.txt");
Files.writeString(cache, "subscription-content");
when(refresh.cachedPath(1, "cat")).thenReturn(cache);
var request = signedGet(event(NOW), "GET");
request.removeHeader("X-Subscription-Signature");
request.addHeader("X-Subscription-Signature", "0".repeat(64));
request.setRemoteAddr("198.51.100.10");
sub.updateSub(new MockHttpServletResponse(), request, "cat", KEY);
assertEquals(1, count("sub_update_record"));
assertEquals(0, count("subscription_event_receipt"));
assertEquals("198.51.100.10", db.queryForObject("select ip from sub_update_record", String.class));
sub.shutdownLocationResolver();
}
@Test void invalidInputAndAuthenticationReturnNoAcknowledgements() throws Exception {
var controller = new SubscriptionEventController(service);
var unauthenticated = new MockHttpServletRequest("POST", "/internal/subscription-events");
unauthenticated.setContent(batch(event(NOW)));
assertEquals(403, controller.ingest(unauthenticated).getStatusCode().value());
assertEquals(400, controller.ingest(signedPost("{}".getBytes(StandardCharsets.UTF_8))).getStatusCode().value());
assertEquals(400, controller.ingest(signedPost(new byte[SubscriptionEventService.MAX_BODY + 1])).getStatusCode().value());
SubscriptionEvent future = event(NOW + 300_001);
assertThrows(IllegalArgumentException.class, () -> service.parseBatch(batch(future)));
SubscriptionEvent tooLong = event(NOW);
tooLong.setUserAgent("a".repeat(2049));
assertThrows(IllegalArgumentException.class, () -> service.parseBatch(batch(tooLong)));
assertEquals(0, count("subscription_event_receipt"));
}
@Test void conflictingIdCannotAcknowledgeOrWriteChangedEvent() {
SubscriptionEvent original = event(NOW);
service.ingest(List.of(original));
original.setClientIp("192.0.2.7");
assertThrows(IllegalArgumentException.class, () -> service.ingest(List.of(original)));
assertEquals(1, count("sub_update_record"));
}
private int count(String table) { return db.queryForObject("select count(*) from " + table, Integer.class); }
private SubscriptionEvent event(long time) {
return new SubscriptionEvent(UUID.randomUUID().toString(), SubscriptionEventService.sha256(KEY.getBytes(StandardCharsets.UTF_8)),
"cat", "2001:db8::7", "client/1.2", time);
}
private static Map<String, Object> asMap(SubscriptionEvent event) {
return Map.of("eventId", event.getEventId(), "keyHash", event.getKeyHash(), "client", event.getClient(),
"clientIp", event.getClientIp(), "userAgent", event.getUserAgent(), "occurredAt", event.getOccurredAt());
}
private static byte[] batch(SubscriptionEvent event) {
return CustomUtil.objectMapper.writeValueAsBytes(Map.of("events", List.of(asMap(event))));
}
private static String sign(byte[] body, String timestamp) throws Exception {
var mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(("v1\n" + timestamp + "\n" + SubscriptionEventService.sha256(body)).getBytes(StandardCharsets.UTF_8)));
}
private MockHttpServletRequest signedGet(SubscriptionEvent event, String method) throws Exception {
byte[] body = CustomUtil.objectMapper.writeValueAsBytes(asMap(event));
var request = new MockHttpServletRequest(method, "/sub/cat/" + KEY);
request.setRemoteAddr("127.0.0.1");
request.addHeader("User-Agent", event.getUserAgent());
request.addHeader("X-Subscription-Event", Base64.getUrlEncoder().withoutPadding().encodeToString(body));
String timestamp = Long.toString(NOW / 1000);
request.addHeader("X-Subscription-Timestamp", timestamp);
request.addHeader("X-Subscription-Signature", sign(body, timestamp));
return request;
}
private MockHttpServletRequest signedPost(byte[] body) throws Exception {
var request = new MockHttpServletRequest("POST", "/internal/subscription-events");
request.setContent(body);
String timestamp = Long.toString(NOW / 1000);
request.addHeader("X-Subscription-Timestamp", timestamp);
request.addHeader("X-Subscription-Signature", sign(body, timestamp));
return request;
}
}