test(license): RetentionRuntimeRecomputeIT — TTL recompute on license change

Install license with max_log_retention_days=30, env.configured=60 →
effective=30; verify ClickHouse logs table reflects toIntervalDay(30).
Replace with max=7 → effective=7; verify TTL recomputed. Polls
system.tables.create_table_query up to 5s for the @Async listener
to apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 16:08:28 +02:00
parent 1e78439ddd
commit e198c13e8a

View File

@@ -0,0 +1,128 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState;
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.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test for Spec §4.3 — when a license changes, {@link RetentionPolicyApplier}
* re-issues {@code ALTER TABLE … MODIFY TTL … WHERE environment = '<slug>'} statements
* against ClickHouse so per-env retention reflects the new {@code min(licenseCap, env.configured)}.
*
* <p><b>Strategy:</b> the applier is an {@code @Async @EventListener}. To avoid timing
* flakiness, this IT calls {@link RetentionPolicyApplier#onLicenseChanged} synchronously
* after seeding the {@link LicenseGate} via
* {@link TestSecurityHelper#installSyntheticUnsignedLicense(Map)}. The async dispatch
* and the SQL-shape correctness are already covered by {@code RetentionPolicyApplierTest}
* (unit, mocked CH); this IT adds the missing real-CH ALTER round-trip.</p>
*
* <p><b>Env retention setup:</b> V1 seeds the default env with retention=1 day. To make
* the license cap the binding constraint of {@code min(cap, configured)}, this test
* raises the {@code default} env's {@code log_retention_days} to 60 in {@code @BeforeEach}
* via raw JDBC (the {@code EnvironmentRepository} interface deliberately does not expose
* retention-day setters — admin endpoints exist for them, but the controller plumbing is
* out of scope here). Restored to 1 in {@code @AfterEach}.</p>
*
* <p><b>Assertion target:</b> {@code system.tables.create_table_query} in ClickHouse
* reflects the most recent {@code MODIFY TTL} clause as a row-level TTL with WHERE
* predicate (CH 22.3+; project runs 24.12). Polls up to 5s for any other ALTERs that
* the runtime may have queued.</p>
*/
class RetentionRuntimeRecomputeIT extends AbstractPostgresIT {
@Autowired
@Qualifier("clickHouseJdbcTemplate")
JdbcTemplate clickHouseJdbc;
@Autowired
TestSecurityHelper securityHelper;
@Autowired
LicenseGate gate;
@Autowired
RetentionPolicyApplier applier;
@BeforeEach
void seedHighEnvRetention() {
// Sibling ITs may have left a license in the gate — clear so default tier is the baseline.
securityHelper.clearTestLicense();
// Raise default env's log retention to 60 so license cap becomes the binding constraint.
jdbcTemplate.update(
"UPDATE environments SET log_retention_days = 60 WHERE slug = 'default'");
}
@AfterEach
void restoreDefaults() {
securityHelper.clearTestLicense();
jdbcTemplate.update(
"UPDATE environments SET log_retention_days = 1 WHERE slug = 'default'");
// Re-fire applier with cleared gate (default tier, log cap = 1) so subsequent ITs
// observe the schema's seeded TTL, not whatever this test last set.
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
}
@Test
void changingLicenseRecomputesLogsTtl() throws Exception {
// (1) Install license with cap = 30. With env.configured = 60, effective = min(30, 60) = 30.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_log_retention_days", 30));
assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
assertThat(gate.getEffectiveLimits().get("max_log_retention_days")).isEqualTo(30);
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
awaitTtlInterval("logs", 30, 5_000);
// (2) Replace license: cap = 7. effective = min(7, 60) = 7.
securityHelper.clearTestLicense();
securityHelper.installSyntheticUnsignedLicense(Map.of("max_log_retention_days", 7));
assertThat(gate.getEffectiveLimits().get("max_log_retention_days")).isEqualTo(7);
applier.onLicenseChanged(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
awaitTtlInterval("logs", 7, 5_000);
}
/**
* Polls {@code system.tables.create_table_query} until it contains the expected
* day-interval fragment for the given environment, or the deadline elapses. The
* synchronous applier call already completed before this is invoked — the poll
* just guards against any latent ClickHouse internal propagation delay between
* the ALTER returning and {@code system.tables} reflecting the change.
*
* <p>ClickHouse normalises {@code INTERVAL N DAY} in the stored {@code TTL}
* clause to {@code toIntervalDay(N)} when serialising back to {@code create_table_query},
* so we match the canonical form. The {@code WHERE environment = '<slug>'}
* predicate is included so a stale TTL for a different env can't satisfy the
* assertion.</p>
*/
private void awaitTtlInterval(String table, int days, long timeoutMs) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutMs;
String fragment = "toIntervalDay(" + days + ") WHERE environment = 'default'";
String last = null;
while (System.currentTimeMillis() < deadline) {
last = clickHouseJdbc.queryForObject(
"SELECT create_table_query FROM system.tables "
+ "WHERE name = ? AND database = currentDatabase()",
String.class, table);
if (last != null && last.contains(fragment)) {
return;
}
Thread.sleep(100);
}
throw new AssertionError(
"Timed out waiting for " + table + " TTL to contain '" + fragment
+ "'. Last create_table_query:\n" + last);
}
}