Compare commits

...
Author SHA1 Message Date
james.maesandGPT-6 2de0d11a10 test: verify shared sessions after Redis promotion
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 07:58:17 -05:00
james.maesandCodex 0f50ca15af fix: upgrade session-store test assertions to patched AssertJ
Use3.27.7 for GHSA-rqfh-9r24-8c9r. Verified synthetic external-entity/DTD rejection, ordinary XML control, dependency resolution,67unit and29integration tests with coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 14:44:20 -05:00
3 changed files with 158 additions and 1 deletions

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# Shared sessions during controlled Redis promotion
Tracks [QQQ #1301](https://github.com/QRun-IO/qqq/issues/1301). This is a test of
the actual shared session provider, complementary to the connection and Pub/Sub
promotion fixture in Redis draft PR4. No production provider behavior changes.
`SharedRedisSessionStoreProviderIT.replicatedSessionsAndRevocationsSurviveControlledPromotion`
uses a real Redis primary/replica pair and three separately configured QQQ
instances. One writes to the primary; two use the replica endpoint, with fixed
and sliding expiry settings. The test verifies:
- Session data reaches the replica and writes to the read-only replica fail.
- A removed session is absent after a subsequent write reaches the replica.
This observation establishes that this test's deletion was replicated.
- After the old primary stops, its provider raises an infrastructure failure
rather than reporting a normal session miss.
- After promotion, the original session remains readable and the replicated
revocation remains absent. Touch/read-and-touch cannot recreate it.
- New sessions, cross-node deletion, sliding expiry and natural expiry work on
the promoted endpoint. Touch cannot recreate an expired session.
Verification passed on October 26, 2026: 67 unit tests and 30 integration tests,
no failures/errors/skips, zero Checkstyle violations, and coverage gates passed.
The first full run encountered a replica read timeout during initial loading;
the fixture now retries connection failures only inside its bounded 15-second
initial replication wait. Post-startup assertions and runtime timeouts are
unchanged. The focused test also passed before that startup adjustment.
Containers, network, provider registrations and runtime pools belong to the
fixture and are cleaned up by it. The existing Maven wrapper from the core
checkout can run this repository's POM on Windows, where this checkout has no
`mvnw.cmd`.
## Limits and remaining qualification
The test deliberately waits for replication before stopping the primary. It
does not prove that unreplicated session creation, permission changes or logout
survive failover. Redis replication is asynchronous; recovered connectivity
alone does not establish durable authentication state. See the official
[replication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/).
Before production failover support, qualify the actual host endpoint routing,
old-primary fencing, TLS/authentication, and the policy for lost or restored
authentication state. In particular, a logout lost before replication can leave
an older session copy on the promoted node; this fixture must not be cited as
proof that such a session cannot return. Presence generation rotation alone
does not invalidate session-store data.
This uses an unchanged explicit replica endpoint and controlled promotion. It
does not implement native Sentinel discovery, Cluster routing or automatic
application reconfiguration, and does not qualify a deployed Kof22 cluster.
+1 -1
View File
@@ -137,7 +137,7 @@
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
@@ -45,6 +45,113 @@ import static org.junit.jupiter.api.Assertions.*;
*******************************************************************************/
class SharedRedisSessionStoreProviderIT
{
/***************************************************************************
** Observed replicated sessions and revocations survive controlled promotion.
** This does not claim survival of writes lost before asynchronous replication.
***************************************************************************/
@Test
void replicatedSessionsAndRevocationsSurviveControlledPromotion() throws Exception
{
QInstance original = QContext.getQInstance();
try(var network = org.testcontainers.containers.Network.newNetwork();
var primary = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withNetworkAliases("primary").withExposedPorts(6379);
var replica = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withExposedPorts(6379)
.withCommand("redis-server", "--replicaof", "primary", "6379"))
{
primary.start();
replica.start();
QInstance first = host(primary, "promotion", true);
QInstance second = host(replica, "promotion", false);
QInstance third = host(replica, "promotion", true);
var firstRuntime = new RedisRuntimeService();
var secondRuntime = new RedisRuntimeService();
var thirdRuntime = new RedisRuntimeService();
try
{
firstRuntime.start(first);
secondRuntime.start(second);
thirdRuntime.start(third);
var a = QSessionStoreQBitContext.getProvider(first);
var b = QSessionStoreQBitContext.getProvider(second);
var c = QSessionStoreQBitContext.getProvider(third);
a.store("active", new QSession().withValue("owner", "alice"), Duration.ofSeconds(60));
a.store("revoked", new QSession().withValue("owner", "bob"), Duration.ofSeconds(60));
assertTimeoutPreemptively(Duration.ofSeconds(15), () ->
{
while(true)
{
try
{
if(b.load("revoked").isPresent())
{
break;
}
}
catch(redis.clients.jedis.exceptions.JedisConnectionException initialSync)
{
// Initial replica loading can temporarily block reads; the outer deadline stays bounded.
}
Thread.sleep(25);
}
});
assertThrows(RuntimeException.class, () -> b.store("replica-write", new QSession(), null));
a.remove("revoked");
// Seeing a later write proves this test's deletion reached the replica.
a.store("barrier", new QSession(), Duration.ofSeconds(60));
assertTimeoutPreemptively(Duration.ofSeconds(5), () ->
{
while(b.load("barrier").isEmpty())
{
Thread.sleep(25);
}
});
assertTrue(b.load("revoked").isEmpty());
primary.stop();
assertThrows(RuntimeException.class, () -> a.load("active"));
var promoted = replica.execInContainer("redis-cli", "REPLICAOF", "NO", "ONE");
assertEquals(0, promoted.getExitCode());
assertEquals("OK", promoted.getStdout().trim());
assertEquals("alice", b.load("active").orElseThrow().getValue("owner"));
assertTrue(c.loadAndTouch("revoked").isEmpty());
c.touch("revoked");
assertTrue(b.load("revoked").isEmpty());
b.store("new", new QSession().withValue("owner", "carol"), Duration.ofSeconds(1));
assertEquals("carol", c.loadAndTouch("new").orElseThrow().getValue("owner"));
var namespace = new RedisNamespace("test", "promotion", "authentication", "session-store");
var client = RedisRuntimeService.connection(second, "sessions").client();
assertTrue(client.pttl(namespace.key("new")) > 20000);
c.remove("active");
assertTrue(b.load("active").isEmpty());
b.store("expires", new QSession(), Duration.ofMillis(200));
assertTimeoutPreemptively(Duration.ofSeconds(3), () ->
{
while(b.load("expires").isPresent())
{
Thread.sleep(25);
}
});
c.touch("expires");
assertTrue(c.loadAndTouch("expires").isEmpty());
}
finally
{
for(var runtime : List.of(firstRuntime, secondRuntime, thirdRuntime))
{
runtime.stop();
runtime.afterApplicationStop();
}
for(var instance : List.of(first, second, third))
{
QSessionStoreQBitContext.clear(instance);
QSessionStoreRegistry.getInstance(instance).clear();
}
QContext.setQInstance(original);
}
}
}
/***************************************************************************
** Separate nodes share one app's sessions while other apps remain isolated.
***************************************************************************/