Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.1 366e5d8af4 feat(enterprise): the assistant answers, with Claude when keyed or a local Ollama
The assistant qbit was installed with no model provider, and the
enterprise session lacked the literal assistant.hasAccess permission
(not in getAllAvailablePermissionNames), so every user saw NO_PERMISSION.
Sessions now hold assistant.hasAccess and assistant.admin, and the
profile registers the Anthropic and OpenAI-compatible provider types with
two provider rows reconciled at every start: Anthropic (key variable
ANTHROPIC_API_KEY, name only) is the default when the variable is set,
else a local Ollama (127.0.0.1:11434/v1, QQQ_ASSISTANT_OLLAMA_MODEL,
default llama3.3:70b-instruct-q4_K_M) when it answers and lists the
model, else neither and the assistant reports NOT_CONFIGURED. A turn may
take 900 s and an Ollama call 600 s for a local 70B model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:12:16 -06:00
16 changed files with 222 additions and 360 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /.*/
-33
View File
@@ -1,33 +0,0 @@
name: build
on:
push:
jobs:
container:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/container.yaml@v1
secrets: inherit
with:
# Same gates as the old orb build-and-test + core smoke: script unit tests,
# the full reactor (BOM tests, packaged integration tests), then the packaged
# core jar smoked over HTTP. The Dockerfile copies that verified jar.
prebuild: |
set -euo pipefail
# The smoke scripts need Python 3 (stdlib only); the job image has none and no root for apt.
mkdir -p /tmp/python
curl -fsSLo /tmp/python.tgz https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.12.15%2B20261013-x86_64-unknown-linux-gnu-install_only.tar.gz
echo "5d546fc004fc2435fa142eb8f42ed8de5eb4725963dc22f7e6671014a80eb560 /tmp/python.tgz" | sha256sum -c -
tar -xzf /tmp/python.tgz -C /tmp/python --strip-components=1 && rm /tmp/python.tgz
# The release-bundle and full-smoke config tests render compose.yaml with `docker compose config`
# (no daemon). Provide the Compose binary behind a `docker compose`-only shim.
curl -fsSLo /tmp/python/bin/docker-compose https://github.com/docker/compose/releases/download/v5.6.1/docker-compose-linux-x86_64
echo "47737f4236e6d3ccea7291995203f8f018058783b4c3a9469119811a0b5db8c0 /tmp/python/bin/docker-compose" | sha256sum -c -
printf '%s\n' '#!/bin/sh' '[ "$1" = compose ] || { echo "only docker compose is installed" >&2; exit 1; }' 'shift' 'exec docker-compose "$@"' > /tmp/python/bin/docker
chmod +x /tmp/python/bin/docker-compose /tmp/python/bin/docker
export PATH=/tmp/python/bin:$PATH
python3 -m unittest discover -s scripts -p 'test_*.py' -v
mvn verify
python3 scripts/run_core_smoke.py qqq-all-app/target/qqq-all-app.jar
images: |
qqq-all|Dockerfile|.
# develop pushes git.kof22.com/qrun-io/qqq-all:ci-<sha>-run-<n> plus the moving develop tag.
publish-branch: develop
extra-tags: develop
+53
View File
@@ -0,0 +1,53 @@
name: Release 4.1.0
'on':
workflow_dispatch:
inputs:
version:
description: Reviewed source version (GA or exact coordinated RC)
required: true
type: choice
default: 4.1.0
options:
- 4.1.0
- 4.1.0-RC.1
release_sha:
description: Full develop commit SHA already signed and tagged with the selected
version
required: true
type: string
smoke_run_id:
description: Successful Smoke workflow run ID for that exact develop SHA
required: false
type: string
default: ''
smoke_evidence:
description: Evidence provider; github preserves the original Smoke workflow
validation
type: choice
default: circle
options:
- github
- circle
required: true
circle_workflow_id:
description: Exact successful Circle workflow UUID when circle evidence is
selected
type: string
required: false
default: ''
permissions:
actions: read
contents: write
packages: write
checks: read
jobs:
release:
uses: QRun-IO/qqq-orb/.github/workflows/qqq-all-release.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
version: ${{ inputs.version }}
release_sha: ${{ inputs.release_sha }}
smoke_run_id: ${{ inputs.smoke_run_id }}
smoke_evidence: ${{ inputs.smoke_evidence }}
circle_workflow_id: ${{ inputs.circle_workflow_id }}
secrets:
CIRCLECI_TOKEN: ${{ secrets.CIRCLECI_TOKEN }}
+6
View File
@@ -0,0 +1,6 @@
profile: all
orb_version: '0.8'
default_branch: develop
publish:
snapshot: true
publish_context: qqq-maven-registry-credentials
-1
View File
@@ -1,7 +1,6 @@
FROM eclipse-temurin:21-jre-jammy
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 qqq \
+2 -2
View File
@@ -41,9 +41,9 @@ Full adds sample tables on PostgreSQL, MySQL, MongoDB, MinIO S3, and SFTP. It in
To stop the full stack, run `docker compose --profile full down`. Add `--volumes` only when you intend to erase demo data. For a fresh Artemis volume, the configured login is required; old volumes created for anonymous access should be recreated. This reference stack uses demo credentials and is not a production deployment.
CI runs [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) through the shared `KofTwentyTwo/ci-workflows` container workflow: script unit tests, `mvn verify`, and the packaged core HTTP smoke check on every push, then the image build and Trivy scan. Pushes to `develop` publish `git.kof22.com/qrun-io/qqq-all` (`ci-<sha>-run-<n>` and `develop`). The full Compose smoke check runs locally with `python3 scripts/run_full_smoke.py`.
The [canonical QQQ pipeline](.circleci/config.yml), configured by [.qqq-ci.yml](.qqq-ci.yml), builds once and runs core/full smoke checks through the shared orb. Its develop-image job publishes to GHCR only after the container package exists and its visibility is public. Until then, image publication is skipped and local Compose remains available.
The [4.1.0 release procedure](docs/release.md) describes the separate Maven parent/BOM publication path. The GitHub release workflow that called the retired qqq-orb was removed; a versioned release path on Gitea is not defined yet.
The [4.1.0 release procedure](docs/release.md) describes the separate Maven parent/BOM publication path and the manually gated, exact-commit [release workflow](.github/workflows/release.yml). The workflow will not release the current snapshot build.
## Contributing and license
+1 -1
View File
@@ -1,6 +1,6 @@
# 4.1.0 release preparation
This procedure prepares a release; this repository currently remains on `4.1.0-SNAPSHOT`. The GitHub manual release workflow (GHCR image plus Compose bundle, via the retired qqq-orb) was removed with the move to Gitea CI; `develop` images now publish to `git.kof22.com/qrun-io/qqq-all`. A versioned release path on Gitea is not defined yet.
This procedure prepares a release; this repository currently remains on `4.1.0-SNAPSHOT`. The [manual release workflow](../.github/workflows/release.yml) publishes the versioned GHCR image and a GitHub release containing an image-based Compose bundle plus `compose.yaml`. It does **not** deploy Maven artifacts or create a tag. Publication is authorized once the release gates pass. Keep the workflow disabled until Apache-2.0 alignment, public artifact verification and GHCR visibility are complete.
## Exact coordinated RC under QQQ #921
@@ -51,8 +51,6 @@ import com.kingsrook.qqq.backend.core.model.savedreports.SavedReportsMetaDataPro
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.common.TimeZonePossibleValueSourceMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
import com.kingsrook.qqq.backend.module.filesystem.sftp.model.metadata.SFTPTableBackendDetails;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/** Registers the locally usable qbits against the demo's relational backend. */
@@ -175,12 +173,6 @@ final class DemoQBits
new SavedBulkLoadProfileMetaDataProvider().defineAll(instance, "postgres", null);
new SavedReportsMetaDataProvider().defineAll(instance, "postgres", "sftp", null);
///////////////////////////////////////////////////////////////////////////
// rendered reports are files, one per record, on the SFTP server: core //
// now refuses a filesystem table without backend details (qqq#1364) //
///////////////////////////////////////////////////////////////////////////
instance.getTable("reportStorage").setBackendDetails(new SFTPTableBackendDetails().withBasePath("reportStorage")
.withCardinality(Cardinality.ONE).withFileNameFieldName("reference").withContentsFieldName("contents"));
new SFTPDataIntegrationQBitProducer().withSftpDataIntegrationQBitConfig(new SFTPDataIntegrationQBitConfig()
.withSourceFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
.withDestinationFileTableConfig(ProvidedOrSuppliedTableConfig.provideTableUsingBackendNamed("sftp"))
@@ -88,48 +88,28 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
}
if(EnterpriseFeatures.isEnabled())
{
grantEnterpriseAccess(instance, session, userId);
//////////////////////////////////////////////////////////////////////
// enterprise test server: every session is an all-access admin, so //
// the terminal clients can reach every qbit surface //
//////////////////////////////////////////////////////////////////////
session.withPermissions(PermissionsHelper.getAllAvailablePermissionNames(instance));
//////////////////////////////////////////////////////////////////////
// permissions some modules check by literal name, outside the //
// table/process permission rules (QRun-IO/qqq#1362) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
//////////////////////////////////////////////////////////////////
// per-user keys (owner locks of qbit tables) take the user id //
//////////////////////////////////////////////////////////////////
session.withSecurityKeyValue(keyType.getName(), userId);
}
}
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
if(EnterpriseFeatures.isEnabled())
{
EnterpriseSessions.record(session);
}
}
/**
* The enterprise test server: every session is an all-access admin, so the
* terminal clients can reach every qbit surface.
*/
static void grantEnterpriseAccess(QInstance instance, QSession session, String userId)
{
//////////////////////////////////////////////////////////////////////
// enterprise test server: every session is an all-access admin, so //
// the terminal clients can reach every qbit surface //
//////////////////////////////////////////////////////////////////////
session.withPermissions(PermissionsHelper.getAllAvailablePermissionNames(instance));
//////////////////////////////////////////////////////////////////////
// permissions some modules check by literal name, outside the //
// table/process permission rules (QRun-IO/qqq#1362) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("jobs_job.read", "jobs_admin", "data_analysis.admin", "assistant.hasAccess", "assistant.admin");
//////////////////////////////////////////////////////////////////////
// qbit-collaboration's custom checkers (Custom Fields, tags, the //
// admin tables) test these names; without them the Custom Fields //
// table answered 403 "This needs collaboration.view." (#47) //
//////////////////////////////////////////////////////////////////////
session.withPermissions("collaboration.view", "collaboration.admin", "collaboration.fields.manage", "collaboration.tags.manage");
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
//////////////////////////////////////////////////////////////////
// per-user keys (owner locks of qbit tables) take the user id //
//////////////////////////////////////////////////////////////////
session.withSecurityKeyValue(keyType.getName(), userId);
}
}
}
@@ -88,6 +88,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.ColumnStatsStep;
@@ -427,8 +431,8 @@ final class EnterpriseFeatures
** the web UI and the terminal clients expect; filesystem and API backends
** cannot aggregate and stay without it. Tables of those backends that list
** QUERY_STATS as disabled keep it off. The columnStats process that serves
** the statistics is added when the instance lacks it; core guards it by field
** access (QRun-IO/qqq#1367: only fully readable fields, results tagged).
** the statistics is added when the instance lacks it, and runs through
** GuardedColumnStatsStep (only fully readable fields).
*******************************************************************************/
static void enableColumnStatistics(QInstance instance)
{
@@ -440,6 +444,20 @@ final class EnterpriseFeatures
instance.addProcess(ColumnStatsStep.getProcessMetaData());
}
////////////////////////////////////////////////////////////////////////
// run it through the guarded step: fields the caller cannot read in //
// full are refused, and the results carry the analysed table so its //
// field-access policy applies (QRun-IO/qqq#1367) //
////////////////////////////////////////////////////////////////////////
QProcessMetaData process = instance.getProcess(ColumnStatsStep.getProcessMetaData().getName());
for(QStepMetaData step : process.getStepList())
{
if(step instanceof QBackendStepMetaData backendStep && backendStep.getCode() != null
&& ColumnStatsStep.class.getName().equals(backendStep.getCode().getName()))
{
backendStep.setCode(new QCodeReference(GuardedColumnStatsStep.class));
}
}
for(QBackendMetaData backend : instance.getBackends().values())
{
if(backend instanceof RDBMSBackendMetaData || new MemoryBackendModule().getBackendType().equals(backend.getBackendType()))
@@ -1,83 +0,0 @@
/*
* 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,98 @@
/*
* 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.io.Serializable;
import java.util.Collection;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.ColumnStatsStep;
/**
* Column statistics only for fields the caller may read in full.
*
* Statistics expose values (min, max, the most common values), so a field
* whose access tier for the caller is REVEALABLE, MASKED or HIDDEN is refused
* with a permission error before anything is computed. The result records are
* then tagged with the analysed table, so the UI API applies that table's
* field-access policy to them instead of withholding table-less records.
* The process shape belongs in qqq core (QRun-IO/qqq#1367); this is the host's
* safe check until then.
*/
public class GuardedColumnStatsStep extends ColumnStatsStep
{
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String tableName = input.getValueString("tableName");
String fieldName = input.getValueString("fieldName");
QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName);
assertFullAccess(table, fieldName);
super.run(input, output);
if(table != null)
{
for(Serializable value : output.getValues().values())
{
tag(table.getName(), value);
}
}
}
/**
* Refuse statistics for a field the caller cannot read in full; a joined
* field ("otherTable.field") is checked on its own table.
*/
static void assertFullAccess(QTableMetaData table, String fieldName) throws QPermissionDeniedException
{
if(table == null || fieldName == null)
{
return;
}
QTableMetaData fieldTable = table;
String name = fieldName;
int dot = fieldName.indexOf('.');
if(dot > 0 && QContext.getQInstance().getTable(fieldName.substring(0, dot)) != null)
{
fieldTable = QContext.getQInstance().getTable(fieldName.substring(0, dot));
name = fieldName.substring(dot + 1);
}
FieldAccessTier tier = FieldAccessHelper.getTier(fieldTable, name);
if(tier != FieldAccessTier.FULL)
{
String label = fieldTable.getField(name) == null ? name : fieldTable.getField(name).getLabel();
throw new QPermissionDeniedException("Column statistics are not available for " + label
+ ": you do not have full access to this field.");
}
}
private static void tag(String tableName, Serializable value)
{
if(value instanceof QRecord record && record.getTableName() == null)
{
record.setTableName(tableName);
}
else if(value instanceof Collection<?> collection)
{
for(Object element : collection)
{
if(element instanceof QRecord record && record.getTableName() == null)
{
record.setTableName(tableName);
}
}
}
}
}
@@ -1,63 +0,0 @@
/*
* 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.nio.file.Path;
import java.util.Map;
import com.kingsrook.qbits.collaboration.customfields.FieldSecurity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** The enterprise admin passes qbit-collaboration's Custom Fields checker (qqq-all#47). */
class EnterpriseAccessTest
{
@TempDir Path dataDirectory;
@AfterEach
void tearDown()
{
QContext.clear();
}
@Test
void adminCanReadAndManageCustomFields() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
QSession plain = new QSession();
QContext.init(instance, plain);
assertThrows(QPermissionDeniedException.class,
() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
QSession admin = new QSession();
DemoUsers.grantEnterpriseAccess(instance, admin, "core:admin");
QContext.init(instance, admin);
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new QueryInput("collaboration_field_definition"), null));
assertDoesNotThrow(() -> new FieldSecurity().checkPermissionsThrowing(new InsertInput("collaboration_field_definition"), null));
}
@Test
void customizedSessionsStillCarryTheUserForTheFrontend() throws Exception
{
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
QSession session = new QSession();
session.setUuid(DemoUsers.ADMIN_SESSION);
session.setUser(new QUser());
new DemoUsers().customizeSession(instance, session, Map.of());
assertEquals("Admin", ((Map<?, ?>) session.getValuesForFrontend().get("user")).get("name"));
}
}
@@ -1,115 +0,0 @@
/*
* 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));
}
}
}
@@ -38,12 +38,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The enterprise profile's columnStats process (core's step, QRun-IO/qqq#1367)
* refuses statistics for a field the caller cannot read in full and tags its
* results with the analysed table.
*/
class EnterpriseColumnStatsAccessTest
/** Column statistics are refused for a field the caller cannot read in full. */
class GuardedColumnStatsStepTest
{
private static final String KEY = "salaryViewer";
@@ -83,7 +79,7 @@ class EnterpriseColumnStatsAccessTest
{
QContext.init(instance, new QSession());
QPermissionDeniedException denied = assertThrows(QPermissionDeniedException.class, () -> run("salary"));
assertTrue(denied.getMessage() != null && !denied.getMessage().isBlank());
assertTrue(denied.getMessage().contains("Column statistics are not available for Salary"), denied.getMessage());
}
@Test
+4 -4
View File
@@ -148,11 +148,11 @@ class SmokeTest(unittest.TestCase):
pom = ET.parse(ROOT / "qqq-all-app" / "pom.xml")
final_name = pom.findtext(".//{http://maven.apache.org/POM/4.0.0}build/"
"{http://maven.apache.org/POM/4.0.0}finalName")
# CI smokes the exact jar Maven builds; keep the build output and the
# CI smoke command on the same artifact path.
# The shared all profile owns the smoke command; this repository owns
# the Maven artifact it consumes. Keep that cross-repository contract.
self.assertEqual("qqq-all-app", final_name)
workflow = (ROOT / ".gitea" / "workflows" / "build.yaml").read_text()
self.assertIn("python3 scripts/run_core_smoke.py qqq-all-app/target/qqq-all-app.jar", workflow)
manifest = (ROOT / ".qqq-ci.yml").read_text()
self.assertRegex(manifest, r"(?m)^profile: all$")
def test_standalone_full_smoke_requires_explicit_search_endpoint(self):