From e198c13e8a8e6e1c9e6e3e29dbac5d4cdbaf226e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:08:28 +0200 Subject: [PATCH] =?UTF-8?q?test(license):=20RetentionRuntimeRecomputeIT=20?= =?UTF-8?q?=E2=80=94=20TTL=20recompute=20on=20license=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../license/RetentionRuntimeRecomputeIT.java | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java new file mode 100644 index 00000000..52d7062f --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionRuntimeRecomputeIT.java @@ -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 = ''} statements + * against ClickHouse so per-env retention reflects the new {@code min(licenseCap, env.configured)}. + * + *

Strategy: 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.

+ * + *

Env retention setup: 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}.

+ * + *

Assertion target: {@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.

+ */ +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. + * + *

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 = ''} + * predicate is included so a stale TTL for a different env can't satisfy the + * assertion.

+ */ + 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); + } +}