Gate AI config-push off in SaaS and cover it with tests
This commit is contained in:
@@ -312,6 +312,15 @@ public class ApplicationProperties {
|
||||
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
|
||||
private int streamTimeoutSeconds = 1800;
|
||||
|
||||
/**
|
||||
* Whether the processor pushes admin/settings-derived AI config (models, keys, RAG, limits)
|
||||
* to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a
|
||||
* self-hosted admin drive the engine from the UI. Environment-driven deployments pin this
|
||||
* false (SaaS does in application-saas.properties) so the engine stays entirely
|
||||
* env-controlled and the processor never overrides its config.
|
||||
*/
|
||||
private boolean pushConfigToEngine = true;
|
||||
|
||||
/** Model + provider selection, forwarded to the engine per-request. */
|
||||
private Models models = new Models();
|
||||
|
||||
|
||||
@@ -368,6 +368,7 @@ aiEngine:
|
||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
||||
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
|
||||
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
|
||||
pushConfigToEngine: true # Push admin/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled
|
||||
models:
|
||||
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
|
||||
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
|
||||
|
||||
+16
-6
@@ -22,10 +22,12 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
* from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings
|
||||
* are saved (so model/RAG/limit changes apply without a restart). The engine applies it live
|
||||
* (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty
|
||||
* key/baseUrl/model fields mean "keep the engine's own environment credential", so
|
||||
* environment-driven deployments (where the engine sets {@code STIRLING_ALLOW_CONFIG_PUSH=false}
|
||||
* and rejects the push) stay fully env-controlled. Best-effort and non-blocking: a slow or
|
||||
* unreachable engine never delays or fails Stirling startup.
|
||||
* key/baseUrl/model fields mean "keep the engine's own environment credential".
|
||||
*
|
||||
* <p>Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments
|
||||
* pin it false (SaaS does so in application-saas.properties) so the engine stays entirely
|
||||
* env-controlled and the processor never pushes settings-derived config to it. Best-effort and
|
||||
* non-blocking: a slow or unreachable engine never delays or fails Stirling startup.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -45,6 +47,12 @@ public class AiEngineConfigSync {
|
||||
if (!cfg.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!cfg.isPushConfigToEngine()) {
|
||||
log.debug(
|
||||
"Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled"
|
||||
+ " (the engine is configured from its own environment)");
|
||||
return;
|
||||
}
|
||||
// Engine may still be booting; push on a virtual thread with a few retries so we never
|
||||
// block or crash Stirling startup when the engine is slow or briefly unreachable.
|
||||
Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg));
|
||||
@@ -60,9 +68,11 @@ public class AiEngineConfigSync {
|
||||
// Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so
|
||||
// pushing on a pending-but-not-restarted enable would always fail. The post-restart
|
||||
// startup push covers first-time enablement.
|
||||
AiEngine cfg = applicationProperties.getAiEngine();
|
||||
if (pendingAiEngine == null
|
||||
|| pendingAiEngine.isEmpty()
|
||||
|| !applicationProperties.getAiEngine().isEnabled()) {
|
||||
|| !cfg.isPushConfigToEngine()
|
||||
|| !cfg.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
boolean engineRelevant =
|
||||
@@ -70,7 +80,7 @@ public class AiEngineConfigSync {
|
||||
if (!engineRelevant) {
|
||||
return;
|
||||
}
|
||||
ObjectNode node = buildConfigNode(applicationProperties.getAiEngine());
|
||||
ObjectNode node = buildConfigNode(cfg);
|
||||
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
|
||||
String body = node.toString();
|
||||
Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body));
|
||||
|
||||
+48
@@ -1,10 +1,13 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -283,6 +286,51 @@ class AdminSettingsControllerTest {
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
|
||||
void dropsMaskedSecretValue() {
|
||||
UpdateSettingsRequest request = new UpdateSettingsRequest();
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
settings.put("aiEngine.models.apiKey", "********");
|
||||
settings.put("ui.appName", "My App");
|
||||
request.setSettings(settings);
|
||||
|
||||
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
|
||||
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
// The masked secret is stripped; only the real change is persisted.
|
||||
mocked.verify(
|
||||
() ->
|
||||
GeneralUtils.updateSettingsTransactional(
|
||||
argThat(
|
||||
(Map<String, Object> m) ->
|
||||
!m.containsKey("aiEngine.models.apiKey")
|
||||
&& m.containsKey("ui.appName"))));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("forwards only aiEngine.* pending keys to the engine live-push")
|
||||
void forwardsOnlyAiEngineKeysToLivePush() {
|
||||
UpdateSettingsRequest request = new UpdateSettingsRequest();
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
settings.put("aiEngine.models.provider", "ollama");
|
||||
settings.put("ui.appName", "My App");
|
||||
request.setSettings(settings);
|
||||
|
||||
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
|
||||
controller.updateSettings(request);
|
||||
|
||||
verify(aiEngineConfigSync)
|
||||
.pushLiveAfterSave(
|
||||
argThat(
|
||||
(Map<String, Object> m) ->
|
||||
m.containsKey("aiEngine.models.provider")
|
||||
&& !m.containsKey("ui.appName")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.AiEngine;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* The config-push bridge is a self-hosted-only feature: it lets the admin UI drive the engine's
|
||||
* model/key/RAG/limit config. Environment-driven deployments pin {@code
|
||||
* aiEngine.pushConfigToEngine} false (SaaS does so in application-saas.properties) so the engine
|
||||
* stays entirely env-controlled - these tests lock in that the processor stays silent then while
|
||||
* still pushing when it is enabled.
|
||||
*/
|
||||
class AiEngineConfigSyncTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AiEngineClient aiEngineClient;
|
||||
private AiEngineConfigSync sync;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getAiEngine().setEnabled(true);
|
||||
applicationProperties.getAiEngine().setPushConfigToEngine(true);
|
||||
aiEngineClient = mock(AiEngineClient.class);
|
||||
ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSkippedWhenPushDisabled() throws Exception {
|
||||
applicationProperties.getAiEngine().setPushConfigToEngine(false);
|
||||
|
||||
sync.pushConfigOnStartup();
|
||||
|
||||
// Returns synchronously before spawning the push thread, so no interaction ever happens.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSkippedWhenDisabled() throws Exception {
|
||||
applicationProperties.getAiEngine().setEnabled(false);
|
||||
|
||||
sync.pushConfigOnStartup();
|
||||
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSentWhenEnabledAndPushOn() throws Exception {
|
||||
sync.pushConfigOnStartup();
|
||||
|
||||
// Push runs on a virtual thread; wait for the single POST to /api/v1/config.
|
||||
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void livePushSkippedWhenPushDisabled() throws Exception {
|
||||
applicationProperties.getAiEngine().setPushConfigToEngine(false);
|
||||
|
||||
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
|
||||
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception {
|
||||
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
|
||||
|
||||
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSerialisesTheEngineWireContract() throws Exception {
|
||||
// Distinct values so a dropped/renamed field is detectable. Mirrors
|
||||
// engine/tests/fixtures/processor_config_push.json - keep the two in sync; the engine's
|
||||
// test_config_contract.py validates that same shape on the receiving side.
|
||||
AiEngine ai = applicationProperties.getAiEngine();
|
||||
ai.getModels().setProvider("ollama");
|
||||
ai.getModels().setSmartModel("smart-model-x");
|
||||
ai.getModels().setFastModel("fast-model-x");
|
||||
ai.getModels().setSmartMaxTokens(1111);
|
||||
ai.getModels().setFastMaxTokens(2222);
|
||||
ai.getModels().setApiKey("provider-key-abc");
|
||||
ai.getModels().setBaseUrl("http://engine.example/v1");
|
||||
ai.getRag().setEmbeddingProvider("custom");
|
||||
ai.getRag().setEmbeddingModel("embed-model-x");
|
||||
ai.getRag().setEmbeddingApiKey("embed-key-abc");
|
||||
ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1");
|
||||
ai.getRag().setTopK(33);
|
||||
ai.getRag().setMaxSearches(7);
|
||||
ai.getLimits().setMaxPages(111);
|
||||
ai.getLimits().setMaxCharacters(222222);
|
||||
ai.getLimits().setModelMaxConcurrency(9);
|
||||
|
||||
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
|
||||
sync.pushConfigOnStartup();
|
||||
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
|
||||
|
||||
JsonNode root = JsonMapper.builder().build().readTree(body.getValue());
|
||||
|
||||
JsonNode models = root.get("models");
|
||||
assertEquals("ollama", models.get("provider").asText());
|
||||
assertEquals("smart-model-x", models.get("smartModel").asText());
|
||||
assertEquals("fast-model-x", models.get("fastModel").asText());
|
||||
assertEquals(1111, models.get("smartMaxTokens").asInt());
|
||||
assertEquals(2222, models.get("fastMaxTokens").asInt());
|
||||
assertEquals("provider-key-abc", models.get("apiKey").asText());
|
||||
assertEquals("http://engine.example/v1", models.get("baseUrl").asText());
|
||||
|
||||
JsonNode rag = root.get("rag");
|
||||
assertEquals("custom", rag.get("embeddingProvider").asText());
|
||||
assertEquals("embed-model-x", rag.get("embeddingModel").asText());
|
||||
assertEquals("embed-key-abc", rag.get("embeddingApiKey").asText());
|
||||
assertEquals("http://embed.example/v1", rag.get("embeddingBaseUrl").asText());
|
||||
assertEquals(33, rag.get("topK").asInt());
|
||||
assertEquals(7, rag.get("maxSearches").asInt());
|
||||
|
||||
JsonNode limits = root.get("limits");
|
||||
assertEquals(111, limits.get("maxPages").asInt());
|
||||
assertEquals(222222, limits.get("maxCharacters").asInt());
|
||||
assertEquals(9, limits.get("modelMaxConcurrency").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void livePushSkippedForNonEngineRelevantChange() throws Exception {
|
||||
// features.* is processor-side only; no engine push is warranted.
|
||||
sync.pushLiveAfterSave(Map.of("aiEngine.features.chat", false));
|
||||
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Every AI capability entry point calls the matching {@code require*} before reaching the engine.
|
||||
* These lock in fail-closed behaviour: a 503 when the engine is disabled OR the individual feature
|
||||
* flag is off, and a clean pass only when both are on.
|
||||
*/
|
||||
class AiFeatureGateTest {
|
||||
|
||||
private ApplicationProperties props;
|
||||
private AiFeatureGate gate;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
props = new ApplicationProperties();
|
||||
props.getAiEngine().setEnabled(true); // features default all-on
|
||||
gate = new AiFeatureGate(props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesWhenEngineEnabledAndFeatureOn() {
|
||||
assertDoesNotThrow(() -> gate.requireClassify());
|
||||
assertDoesNotThrow(() -> gate.requireChat());
|
||||
}
|
||||
|
||||
@Test
|
||||
void throws503WhenFeatureFlagOff() {
|
||||
props.getAiEngine().getFeatures().setClassify(false);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> gate.requireClassify());
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void throws503WhenEngineDisabledEvenIfFeatureOn() {
|
||||
props.getAiEngine().setEnabled(false); // feature flag still true
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> gate.requireChat());
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
|
||||
# Activated when the :saas module is on the classpath.
|
||||
|
||||
# ---------- AI engine ----------
|
||||
# The SaaS AI backend is env-driven and reached through the saas AiProxyController, so the
|
||||
# processor must never push settings-derived config to it. Pin the config-push off; the engine
|
||||
# stays entirely environment-controlled.
|
||||
aiEngine.pushConfigToEngine=false
|
||||
|
||||
# ---------- Datasource ----------
|
||||
system.datasource.enableCustomDatabase=true
|
||||
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"models": {
|
||||
"provider": "ollama",
|
||||
"smartModel": "smart-model-x",
|
||||
"fastModel": "fast-model-x",
|
||||
"smartMaxTokens": 1111,
|
||||
"fastMaxTokens": 2222,
|
||||
"apiKey": "provider-key-abc",
|
||||
"baseUrl": "http://engine.example/v1"
|
||||
},
|
||||
"rag": {
|
||||
"embeddingProvider": "custom",
|
||||
"embeddingModel": "embed-model-x",
|
||||
"embeddingApiKey": "embed-key-abc",
|
||||
"embeddingBaseUrl": "http://embed.example/v1",
|
||||
"topK": 33,
|
||||
"maxSearches": 7
|
||||
},
|
||||
"limits": {
|
||||
"maxPages": 111,
|
||||
"maxCharacters": 222222,
|
||||
"modelMaxConcurrency": 9
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Wire-contract test for the processor -> engine config push.
|
||||
|
||||
The Java processor (AiEngineConfigSync.buildConfigNode) serialises the admin AI settings as
|
||||
camelCase JSON and POSTs them to /api/v1/config. Because ConfigPushRequest uses extra="ignore"
|
||||
(so version skew never 422s), a field the engine can no longer map is silently dropped to its
|
||||
default instead of erroring. This test pins the exact camelCase contract: every field in the
|
||||
shared fixture must round-trip onto the model, so a rename on either side fails loudly.
|
||||
|
||||
The same fixture (tests/fixtures/processor_config_push.json) is the contract the Java
|
||||
AiEngineConfigSyncTest asserts its serialiser produces - keep the two in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from stirling.contracts import ConfigPushRequest
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_processor_contract_round_trips_every_field() -> None:
|
||||
payload = _load()
|
||||
req = ConfigPushRequest.model_validate(payload)
|
||||
|
||||
m = payload["models"]
|
||||
assert req.models.provider == m["provider"]
|
||||
assert req.models.smart_model == m["smartModel"]
|
||||
assert req.models.fast_model == m["fastModel"]
|
||||
assert req.models.smart_max_tokens == m["smartMaxTokens"]
|
||||
assert req.models.fast_max_tokens == m["fastMaxTokens"]
|
||||
assert req.models.api_key == m["apiKey"]
|
||||
assert req.models.base_url == m["baseUrl"]
|
||||
|
||||
r = payload["rag"]
|
||||
assert req.rag.embedding_provider == r["embeddingProvider"]
|
||||
assert req.rag.embedding_model == r["embeddingModel"]
|
||||
assert req.rag.embedding_api_key == r["embeddingApiKey"]
|
||||
assert req.rag.embedding_base_url == r["embeddingBaseUrl"]
|
||||
assert req.rag.top_k == r["topK"]
|
||||
assert req.rag.max_searches == r["maxSearches"]
|
||||
|
||||
limits = payload["limits"]
|
||||
assert req.limits.max_pages == limits["maxPages"]
|
||||
assert req.limits.max_characters == limits["maxCharacters"]
|
||||
assert req.limits.model_max_concurrency == limits["modelMaxConcurrency"]
|
||||
|
||||
|
||||
def test_processor_contract_has_no_unmapped_keys() -> None:
|
||||
"""Guard the fixture itself: every wire key must map to a model field (none silently
|
||||
absorbed by extra="ignore"). If the processor adds a field, extend the contract here."""
|
||||
payload = _load()
|
||||
expected = {
|
||||
"models": {
|
||||
"provider",
|
||||
"smartModel",
|
||||
"fastModel",
|
||||
"smartMaxTokens",
|
||||
"fastMaxTokens",
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
},
|
||||
"rag": {
|
||||
"embeddingProvider",
|
||||
"embeddingModel",
|
||||
"embeddingApiKey",
|
||||
"embeddingBaseUrl",
|
||||
"topK",
|
||||
"maxSearches",
|
||||
},
|
||||
"limits": {"maxPages", "maxCharacters", "modelMaxConcurrency"},
|
||||
}
|
||||
assert set(payload) == set(expected)
|
||||
for section, keys in expected.items():
|
||||
assert set(payload[section]) == keys
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { type TFunction } from "i18next";
|
||||
import { allowConsole } from "@app/tests/failOnConsole";
|
||||
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
|
||||
|
||||
// Passthrough i18n stub: return the provided fallback (2nd arg) or the key.
|
||||
const t = ((key: string, fallback?: string) =>
|
||||
fallback ?? key) as unknown as TFunction<"translation", undefined>;
|
||||
|
||||
const Overview = () => null;
|
||||
|
||||
type Sections = ReturnType<typeof createSaasConfigNavSections>;
|
||||
|
||||
function itemKeys(sections: Sections): string[] {
|
||||
return sections.flatMap((s) => s.items.map((i) => i.key));
|
||||
}
|
||||
|
||||
// The admin AI settings pages exist only in the self-hosted proprietary flavor. SaaS builds its
|
||||
// settings nav from scratch here, so this locks in that the AI group can never leak into SaaS -
|
||||
// if someone later wires the AI sections into the SaaS cascade, this fails loudly.
|
||||
describe("saasConfigNavSections", () => {
|
||||
const AI_ITEM_KEYS = [
|
||||
"adminAiGeneral",
|
||||
"adminAiModels",
|
||||
"adminAiDocuments",
|
||||
"adminAiLimits",
|
||||
];
|
||||
|
||||
it("never exposes the admin AI settings group or its pages", () => {
|
||||
// The shared core nav helper warns it is deprecated; incidental to this test.
|
||||
allowConsole.warn(/createConfigNavSections is deprecated/);
|
||||
const sections = createSaasConfigNavSections(Overview, () => {}, { t });
|
||||
|
||||
const keys = itemKeys(sections);
|
||||
for (const aiKey of AI_ITEM_KEYS) {
|
||||
expect(keys).not.toContain(aiKey);
|
||||
}
|
||||
expect(sections.map((s) => s.title)).not.toContain("AI");
|
||||
});
|
||||
|
||||
it("also hides the AI pages for anonymous users", () => {
|
||||
allowConsole.warn(/createConfigNavSections is deprecated/);
|
||||
const sections = createSaasConfigNavSections(Overview, () => {}, {
|
||||
t,
|
||||
isAnonymous: true,
|
||||
});
|
||||
|
||||
const keys = itemKeys(sections);
|
||||
for (const aiKey of AI_ITEM_KEYS) {
|
||||
expect(keys).not.toContain(aiKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user