Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 1e0a3341b9 feat(enterprise): store mock sign-ins in the session store
The TUI's "Online" tile counts stored sessions, but storedSession had no
rows on the enterprise server: only the OAuth2 module writes to the
session store, and MOCK authentication builds a fresh session per request
and stores nothing, so two terminals signed in as Admin showed "1 online".

The smallest hook: the enterprise profile's mock session customizer
(DemoUsers) calls EnterpriseSessions.record. The first time the JVM sees a
session uuid it stores one row through qbit-session-store (user id, a lean
identity-only copy of the session, expiresAt; create and modify dates come
from the table), or touches the row when it already exists (after a
restart); after that it touches the row at most once a minute, so expiry
slides while a terminal is in use. qbit-session-store needs no change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:41 -06:00
3 changed files with 202 additions and 0 deletions

No files matched your search

@@ -111,5 +111,9 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
}
}
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
if(EnterpriseFeatures.isEnabled())
{
EnterpriseSessions.record(session);
}
}
}
@@ -0,0 +1,83 @@
/*
* Copyright 2026 Kingsrook, LLC
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
package com.kingsrook.qqq.all;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreHelper;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
/**
* Records mock sign-ins in the session store (qbit-session-store) for the
* enterprise profile.
*
* Only the OAuth2 module writes to the session store; MOCK authentication
* builds a fresh session on every request and stores nothing, so the
* storedSession table stayed empty and "who is online" could not count
* terminals. This is the smallest hook: the mock module's session customizer
* calls {@link #record(QSession)}, which stores one row per session uuid
* (user id, a lean copy of the session, expiresAt) the first time this JVM
* sees it and touches it (sliding expiry) at most once a minute after that.
*/
final class EnterpriseSessions
{
static final Duration TOUCH_INTERVAL = Duration.ofMinutes(1);
private static final Map<String, Long> LAST_SEEN = new ConcurrentHashMap<>();
private EnterpriseSessions()
{
}
static void record(QSession session)
{
String uuid = session == null ? null : session.getUuid();
if(uuid == null || !QSessionStoreHelper.isSessionStoreAvailable())
{
return;
}
long now = System.currentTimeMillis();
Long last = LAST_SEEN.get(uuid);
if(last != null && now - last < TOUCH_INTERVAL.toMillis())
{
return;
}
LAST_SEEN.put(uuid, now);
if(last == null && QSessionStoreHelper.loadSession(uuid).isEmpty())
{
QSessionStoreHelper.storeSession(uuid, lean(session), QSessionStoreHelper.getDefaultTtl());
}
else
{
QSessionStoreHelper.touchSession(uuid);
}
}
/** The identity only: the admin session's thousand permissions do not belong in a row. */
static QSession lean(QSession session)
{
QSession lean = new QSession();
lean.setUuid(session.getUuid());
lean.setIdReference(session.getUuid());
if(session.getUser() != null)
{
QUser user = new QUser();
user.setIdReference(session.getUser().getIdReference());
user.setFullName(session.getUser().getFullName());
lean.setUser(user);
}
return (lean);
}
/** Tests start from a clean slate. */
static void forget()
{
LAST_SEEN.clear();
}
}
@@ -0,0 +1,115 @@
/*
* Copyright 2026 Kingsrook, LLC
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
package com.kingsrook.qqq.all;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreProviderInterface;
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/** Mock sign-ins become one stored session per uuid, touched rather than re-stored. */
class EnterpriseSessionsTest
{
private final RecordingStore store = new RecordingStore();
@BeforeEach
void setUp()
{
EnterpriseSessions.forget();
QSessionStoreRegistry.getInstance().clear();
QSessionStoreRegistry.getInstance().register(store);
}
@AfterEach
void tearDown()
{
QSessionStoreRegistry.getInstance().clear();
EnterpriseSessions.forget();
}
@Test
void oneRowPerSessionUuid()
{
EnterpriseSessions.record(session("aaaa", "core:admin"));
EnterpriseSessions.record(session("aaaa", "core:admin"));
EnterpriseSessions.record(session("bbbb", "core:admin"));
assertEquals(List.of("aaaa", "bbbb"), new ArrayList<>(store.sessions.keySet()));
assertEquals("core:admin", store.sessions.get("bbbb").getUser().getIdReference());
assertNull(store.sessions.get("aaaa").getPermissions(), "only the identity is stored");
}
@Test
void aKnownSessionIsTouchedNotStoredAgain()
{
store.sessions.put("cccc", session("cccc", "core:admin"));
EnterpriseSessions.record(session("cccc", "core:admin"));
assertEquals(0, store.stores);
assertEquals(1, store.touches);
}
private static QSession session(String uuid, String userId)
{
QSession session = new QSession();
session.setUuid(uuid);
QUser user = new QUser();
user.setIdReference(userId);
session.setUser(user);
session.withPermissions("customer.read", "customer.edit");
return (session);
}
/** A session store that keeps what it is given and counts calls. */
private static class RecordingStore implements QSessionStoreProviderInterface
{
private final Map<String, QSession> sessions = new LinkedHashMap<>();
private int stores;
private int touches;
@Override
public void store(String sessionUuid, QSession session, Duration ttl)
{
stores++;
sessions.put(sessionUuid, session);
}
@Override
public Optional<QSession> load(String sessionUuid)
{
return (Optional.ofNullable(sessions.get(sessionUuid)));
}
@Override
public void remove(String sessionUuid)
{
sessions.remove(sessionUuid);
}
@Override
public void touch(String sessionUuid)
{
touches++;
}
@Override
public Duration getDefaultTtl()
{
return (Duration.ofHours(1));
}
}
}