diff --git a/document/development/2026-07-18/dev-logs/bugs/model-secret-decryption-status.md b/document/development/2026-07-18/dev-logs/bugs/model-secret-decryption-status.md new file mode 100644 index 0000000..e16a701 --- /dev/null +++ b/document/development/2026-07-18/dev-logs/bugs/model-secret-decryption-status.md @@ -0,0 +1,19 @@ +# 模型密钥解密失败仍显示已配置 + +日期:2026-07-18 +文档路径:document/development/2026-07-18/dev-logs/bugs/model-secret-decryption-status.md + +## 修复记录 + +- 15:54:记录 bug 修复:模型密文无法由当前 `settings.key` 解密时,设置页仍根据非空密文显示“已配置”,掩盖实际不可用状态。 + - Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。 + - 原因:后端配置状态只判断数据库密文字段是否非空,前端本地草稿又可能覆盖服务端状态,均没有验证密文是否能用当前密钥成功解密。 + - 修改:后端序列化配置时以实际解密结果计算模型、OnlyOffice JWT 与 SMTP 密码状态;解密只读取现有主密钥,不再因 GET 设置请求自动创建新 `settings.key`;前端合并本地草稿后重新应用服务端权威密钥状态;新增密钥轮换、密钥文件缺失和前端状态覆盖回归测试。 + - 操作:在 `local-x-financial-linux` 容器内执行设置持久化测试、后端联合回归、前端全量回归、Ruff 和生产构建,并使用真实管理员设置页复验。 + - 验证:主模型和备份模型仍显示“已配置”,当前无法解密的 Embedding 与 Reranker 准确显示“未配置”;后端联合回归 `165 passed`,前端全量回归 `821/821`,生产构建成功。 + - 影响:管理员不会再被伪阳性状态误导;受影响的两个真实 API Key 仍需重新录入,或恢复与密文配套的旧密钥文件后才能恢复检索模型调用。 +- 16:20:补齐已有主密钥格式与长度校验。 + - Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。 + - 修改:加密和解密路径统一要求已有 `settings.key` 解码后为 32 字节;非空但格式错误或长度错误的密钥直接拒绝,不再先生成无法由后续读取路径解密的密文;空文件仍按首次初始化语义生成合法密钥。 + - 验证:新增非法 Base64、短密钥和空密钥文件回归;设置与管理模块后端联合回归 `176 passed`,Ruff 通过;`admin/admin + default` 实际读取状态为主模型/备份模型/OnlyOffice 已配置,Embedding/Reranker/SMTP 未配置。 + - 影响:设置保存不会在损坏主密钥下产生新的不可恢复密文,读取设置也不会通过副作用掩盖密钥文件缺失。 diff --git a/document/development/2026-07-18/dev-logs/bugs/settings-model-draft-reconciliation.md b/document/development/2026-07-18/dev-logs/bugs/settings-model-draft-reconciliation.md new file mode 100644 index 0000000..6a0b79d --- /dev/null +++ b/document/development/2026-07-18/dev-logs/bugs/settings-model-draft-reconciliation.md @@ -0,0 +1,14 @@ +# 设置草稿覆盖服务端模型集合 + +日期:2026-07-18 +文档路径:document/development/2026-07-18/dev-logs/bugs/settings-model-draft-reconciliation.md + +## 修复记录 + +- 16:03:记录 bug 修复:设置页本地会话草稿会整体覆盖服务端模型列表,保存任一设置时可能误删服务端新增槽位,或复活已删除槽位。 + - Git 提交检查:沿用本轮 15:54 拉取检查;upstream `origin/main` 无新提交,本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。 + - 原因:加载时先用 session 草稿的 `models` 整体覆盖服务端集合,后续只同步现存行的密钥状态;后端保存完整 `llmForm` 时会把缺失的自定义槽位视为删除。 + - 修改:以服务端快照的模型集合为权威来源,仅按同一 slot 保留当前内存中未提交且不是掩码的 API Key 输入;服务端新增槽位自动补入,服务端删除槽位不再保留;首次异步加载也保护用户请求期间输入的密钥。 + - 操作:在 `local-x-financial-linux` 容器内运行新增/删除自定义槽位、未提交输入和权威状态合并测试,并执行前端全量回归与生产构建。 + - 验证:定向测试 22 项、前端全量测试 `823/823` 全部通过,Vite 生产构建成功,`git diff --check` 通过。 + - 影响:设置页不会因陈旧浏览器草稿静默改变服务端模型清单,降低误删模型配置和检索能力中断风险。 diff --git a/server/src/app/core/secret_box.py b/server/src/app/core/secret_box.py index 78a074c..f1731b4 100644 --- a/server/src/app/core/secret_box.py +++ b/server/src/app/core/secret_box.py @@ -26,7 +26,7 @@ def get_or_create_secret_key() -> bytes: if SECRET_KEY_FILE.exists(): encoded = SECRET_KEY_FILE.read_text(encoding="utf-8").strip() if encoded: - return base64.urlsafe_b64decode(encoded.encode("ascii")) + return read_secret_key() secret_key = secrets.token_bytes(KEY_BYTES) encoded = base64.urlsafe_b64encode(secret_key).decode("ascii") @@ -34,6 +34,23 @@ def get_or_create_secret_key() -> bytes: return secret_key +def read_secret_key() -> bytes: + """只读取现有主密钥,解密路径不得因缺失密钥而生成新文件。""" + + if not SECRET_KEY_FILE.exists(): + raise ValueError("Secret key file is missing") + + try: + encoded = SECRET_KEY_FILE.read_text(encoding="utf-8").strip() + secret_key = base64.urlsafe_b64decode(encoded.encode("ascii")) + except (OSError, UnicodeError, ValueError) as exc: + raise ValueError("Secret key file is invalid") from exc + + if len(secret_key) != KEY_BYTES: + raise ValueError("Secret key length is invalid") + return secret_key + + def _keystream(secret_key: bytes, nonce: bytes, length: int) -> bytes: chunks: list[bytes] = [] counter = 0 @@ -57,7 +74,14 @@ def encrypt_secret(value: str) -> str: secret_key = get_or_create_secret_key() nonce = secrets.token_bytes(NONCE_BYTES) plaintext = value.encode("utf-8") - ciphertext = bytes(a ^ b for a, b in zip(plaintext, _keystream(secret_key, nonce, len(plaintext)), strict=False)) + ciphertext = bytes( + a ^ b + for a, b in zip( + plaintext, + _keystream(secret_key, nonce, len(plaintext)), + strict=False, + ) + ) mac = hmac.new(secret_key, b"mac:" + nonce + ciphertext, hashlib.sha256).digest() encoded_nonce = base64.urlsafe_b64encode(nonce).decode("ascii") @@ -78,7 +102,7 @@ def decrypt_secret(value: str) -> str: if version != SECRET_BOX_VERSION: raise ValueError("Unsupported secret payload version") - secret_key = get_or_create_secret_key() + secret_key = read_secret_key() nonce = base64.urlsafe_b64decode(encoded_nonce.encode("ascii")) ciphertext = base64.urlsafe_b64decode(encoded_ciphertext.encode("ascii")) expected_mac = base64.urlsafe_b64decode(encoded_mac.encode("ascii")) @@ -87,5 +111,12 @@ def decrypt_secret(value: str) -> str: if not hmac.compare_digest(actual_mac, expected_mac): raise ValueError("Secret payload integrity check failed") - plaintext = bytes(a ^ b for a, b in zip(ciphertext, _keystream(secret_key, nonce, len(ciphertext)), strict=False)) + plaintext = bytes( + a ^ b + for a, b in zip( + ciphertext, + _keystream(secret_key, nonce, len(ciphertext)), + strict=False, + ) + ) return plaintext.decode("utf-8") diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index 3b02c8c..505a7b1 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -126,7 +126,11 @@ class OnlyOfficeRuntimeConfig: jwt_secret: str -def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict[str, object]]: +def serialize_model_rows( + model_rows: dict[str, SystemModelSetting], + *, + configured_slots: set[str], +) -> list[dict[str, object]]: ordered_rows = sorted( model_rows.values(), key=lambda row: (int(row.priority or 0), str(row.slot or "")), @@ -138,7 +142,7 @@ def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict "provider": row.provider, "url": row.endpoint, "apiKey": "", - "apiKeyConfigured": bool(row.api_key_encrypted), + "apiKeyConfigured": row.slot in configured_slots, "modelId": row.model_name, "type": MODEL_CAPABILITY_TO_TYPE.get(str(row.capability or "chat"), "llm"), } @@ -662,13 +666,20 @@ class SettingsService: ) def _decrypt_model_api_key(self, encrypted_value: str, *, slot: str) -> str: + return self._decrypt_saved_secret( + encrypted_value, + label=f"model API key for slot={slot}", + ) + + @staticmethod + def _decrypt_saved_secret(encrypted_value: str, *, label: str) -> str: normalized_value = str(encrypted_value or "").strip() if not normalized_value: return "" try: return decrypt_secret(normalized_value) except ValueError: - logger.warning("Skipping undecryptable model API key for slot=%s", slot) + logger.warning("Skipping undecryptable %s", label) return "" def _ensure_settings_schema(self) -> None: @@ -842,8 +853,8 @@ class SettingsService: else: config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *" - @staticmethod def _serialize( + self, settings_row: SystemSetting, secrets_row: SystemSettingSecret, model_rows: dict[str, SystemModelSetting], @@ -853,6 +864,11 @@ class SettingsService: backup_model = model_rows["backup"] embedding_model = model_rows["embedding"] reranker_model = model_rows["reranker"] + configured_slots = { + slot + for slot, model_row in model_rows.items() + if self._decrypt_model_api_key(model_row.api_key_encrypted, slot=slot) + } return SettingsRead( companyForm={ @@ -886,29 +902,37 @@ class SettingsService: "mainModel": main_model.model_name, "mainEndpoint": main_model.endpoint, "mainApiKey": "", - "mainApiKeyConfigured": bool(main_model.api_key_encrypted), + "mainApiKeyConfigured": "main" in configured_slots, "backupProvider": backup_model.provider, "backupModel": backup_model.model_name, "backupEndpoint": backup_model.endpoint, "backupApiKey": "", - "backupApiKeyConfigured": bool(backup_model.api_key_encrypted), + "backupApiKeyConfigured": "backup" in configured_slots, "embeddingProvider": embedding_model.provider, "embeddingModel": embedding_model.model_name, "embeddingEndpoint": embedding_model.endpoint, "embeddingApiKey": "", - "embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted), + "embeddingApiKeyConfigured": "embedding" in configured_slots, "rerankerProvider": reranker_model.provider, "rerankerModel": reranker_model.model_name, "rerankerEndpoint": reranker_model.endpoint, "rerankerApiKey": "", - "rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted), - "models": serialize_model_rows(model_rows), + "rerankerApiKeyConfigured": "reranker" in configured_slots, + "models": serialize_model_rows( + model_rows, + configured_slots=configured_slots, + ), }, renderForm={ "enabled": settings_row.onlyoffice_enabled, "publicUrl": settings_row.onlyoffice_public_url, "jwtSecret": "", - "jwtSecretConfigured": bool(secrets_row.onlyoffice_jwt_secret_encrypted), + "jwtSecretConfigured": bool( + self._decrypt_saved_secret( + secrets_row.onlyoffice_jwt_secret_encrypted, + label="OnlyOffice JWT secret", + ) + ), }, logForm={ "level": settings_row.log_level, @@ -926,9 +950,14 @@ class SettingsService: "encryption": settings_row.smtp_encryption, "senderName": settings_row.sender_name, "senderAddress": settings_row.sender_address, - "username": settings_row.smtp_username, - "password": "", - "passwordConfigured": bool(secrets_row.smtp_password_encrypted), + "username": settings_row.smtp_username, + "password": "", + "passwordConfigured": bool( + self._decrypt_saved_secret( + secrets_row.smtp_password_encrypted, + label="SMTP password", + ) + ), "alertEnabled": settings_row.alert_enabled, "digestEnabled": settings_row.digest_enabled, "digestTime": settings_row.digest_time, diff --git a/server/tests/test_secret_box.py b/server/tests/test_secret_box.py new file mode 100644 index 0000000..5d75c48 --- /dev/null +++ b/server/tests/test_secret_box.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import base64 + +import pytest + +from app.core import secret_box + + +@pytest.mark.parametrize( + "encoded_key", + [ + "not-valid-base64!", + base64.urlsafe_b64encode(b"short-key").decode("ascii"), + ], +) +def test_encrypt_rejects_invalid_existing_secret_key( + monkeypatch, + tmp_path, + encoded_key: str, +) -> None: + key_file = tmp_path / "settings.key" + key_file.write_text(encoded_key, encoding="utf-8") + monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", key_file) + + with pytest.raises(ValueError): + secret_box.encrypt_secret("probe") + + assert key_file.read_text(encoding="utf-8") == encoded_key + + +def test_encrypt_replaces_empty_key_file_with_valid_key(monkeypatch, tmp_path) -> None: + key_file = tmp_path / "settings.key" + key_file.write_text("", encoding="utf-8") + monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", key_file) + + encrypted = secret_box.encrypt_secret("probe") + + assert secret_box.decrypt_secret(encrypted) == "probe" + assert len(secret_box.read_secret_key()) == secret_box.KEY_BYTES diff --git a/server/tests/test_settings_persistence.py b/server/tests/test_settings_persistence.py index 2770c80..b9c670f 100644 --- a/server/tests/test_settings_persistence.py +++ b/server/tests/test_settings_persistence.py @@ -123,6 +123,60 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None: assert service.load_saved_model_api_key("main") == "persisted-key" +def test_snapshot_marks_undecryptable_model_keys_as_unconfigured(monkeypatch) -> None: + temp_dir = build_temp_secret_dir() + monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") + monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes")) + + with build_session(temp_dir / "settings.db") as db: + service = SettingsService(db) + payload = service.get_settings_snapshot().model_dump() + payload["llmForm"]["mainApiKey"] = "main-secret" + payload["llmForm"]["embeddingApiKey"] = "embedding-secret" + payload["llmForm"]["rerankerApiKey"] = "reranker-secret" + service.save_settings_snapshot(SettingsWrite(**payload)) + + secret_box.SECRET_KEY_FILE.unlink() + secret_box.get_or_create_secret_key() + + snapshot = service.get_settings_snapshot() + configured_by_slot = { + model.slot: model.apiKeyConfigured for model in snapshot.llmForm.models + } + + assert snapshot.llmForm.mainApiKeyConfigured is False + assert snapshot.llmForm.embeddingApiKeyConfigured is False + assert snapshot.llmForm.rerankerApiKeyConfigured is False + assert configured_by_slot["main"] is False + assert configured_by_slot["embedding"] is False + assert configured_by_slot["reranker"] is False + + +def test_snapshot_does_not_create_missing_secret_key_and_marks_secrets_unconfigured(monkeypatch) -> None: + temp_dir = build_temp_secret_dir() + monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") + monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes")) + + with build_session(temp_dir / "settings.db") as db: + service = SettingsService(db) + payload = service.get_settings_snapshot().model_dump() + payload["llmForm"]["mainApiKey"] = "main-secret" + payload["renderForm"]["jwtSecret"] = "onlyoffice-secret" + payload["mailForm"]["password"] = "smtp-secret" + service.save_settings_snapshot(SettingsWrite(**payload)) + + secret_box.SECRET_KEY_FILE.unlink() + + snapshot = service.get_settings_snapshot() + + assert secret_box.SECRET_KEY_FILE.exists() is False + assert snapshot.llmForm.mainApiKeyConfigured is False + assert snapshot.renderForm.jwtSecretConfigured is False + assert snapshot.mailForm.passwordConfigured is False + + def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None: temp_dir = build_temp_secret_dir() monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") diff --git a/web/src/composables/useSettings.js b/web/src/composables/useSettings.js index 166003b..75ce0f5 100644 --- a/web/src/composables/useSettings.js +++ b/web/src/composables/useSettings.js @@ -13,11 +13,13 @@ import { PROVIDER_OPTIONS, SECTION_DEFINITIONS, SESSION_RETENTION_OPTIONS, + applyAuthoritativeSecretStatus, buildDefaultState, buildLlmPayload, buildRenderPayload, computeSectionStatus, isModelConfigReady, + isModelSecretMask, isRenderSecretMask, maskConfiguredModelSecrets, maskConfiguredRenderSecret, @@ -106,13 +108,19 @@ export function useSettings() { nextState = mergeState(nextState, readStoredSettings()) } + nextState = applyAuthoritativeSecretStatus(nextState, snapshot) + if (preserveModelApiKeys) { - nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey - nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey - nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey - nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey + const preserveApiKeyInput = (value) => isModelSecretMask(value) ? '' : value + nextState.llmForm.mainApiKey = preserveApiKeyInput(currentState.llmForm.mainApiKey) + nextState.llmForm.backupApiKey = preserveApiKeyInput(currentState.llmForm.backupApiKey) + nextState.llmForm.embeddingApiKey = preserveApiKeyInput(currentState.llmForm.embeddingApiKey) + nextState.llmForm.rerankerApiKey = preserveApiKeyInput(currentState.llmForm.rerankerApiKey) const modelApiKeysBySlot = new Map( - normalizeLlmModelRows(currentState.llmForm.models).map((row) => [row.slot, row.apiKey]) + normalizeLlmModelRows(currentState.llmForm.models).map((row) => [ + row.slot, + preserveApiKeyInput(row.apiKey) + ]) ) nextState.llmForm.models = normalizeLlmModelRows(nextState.llmForm.models).map((row) => ({ ...row, @@ -148,7 +156,10 @@ export function useSettings() { async function loadSettingsSnapshot() { try { const snapshot = await fetchSettings() - applyLoadedSnapshot(snapshot, { mergeDraft: true }) + applyLoadedSnapshot(snapshot, { + mergeDraft: true, + preserveModelApiKeys: true + }) } catch (error) { persistSettings(pageState.value) updateBrandPreviewFromState(pageState.value) diff --git a/web/src/utils/settingsModelHelper.js b/web/src/utils/settingsModelHelper.js index 85d200a..34df96b 100644 --- a/web/src/utils/settingsModelHelper.js +++ b/web/src/utils/settingsModelHelper.js @@ -483,6 +483,63 @@ export function mergeState(baseState, overrideState) { } } +export function applyAuthoritativeSecretStatus(state, snapshot) { + const stateModelRows = normalizeLlmModelRows(state.llmForm?.models) + const localApiKeysBySlot = new Map( + stateModelRows.map((row) => [ + row.slot, + isModelSecretMask(row.apiKey) ? '' : row.apiKey + ]) + ) + const nextState = { + ...state, + adminForm: { ...(state.adminForm || {}) }, + llmForm: { + ...(state.llmForm || {}), + models: stateModelRows + }, + renderForm: { ...(state.renderForm || {}) }, + mailForm: { ...(state.mailForm || {}) } + } + const snapshotLlmForm = snapshot?.llmForm || {} + const configuredKeyNames = [ + 'mainApiKeyConfigured', + 'backupApiKeyConfigured', + 'embeddingApiKeyConfigured', + 'rerankerApiKeyConfigured' + ] + + for (const key of configuredKeyNames) { + if (Object.hasOwn(snapshotLlmForm, key)) { + nextState.llmForm[key] = Boolean(snapshotLlmForm[key]) + } + } + + if (Array.isArray(snapshotLlmForm.models)) { + const authoritativeModels = normalizeLlmModelRows(snapshotLlmForm.models).map((row) => ({ + ...row, + apiKey: localApiKeysBySlot.get(row.slot) || row.apiKey, + apiKeyConfigured: Boolean(row.apiKeyConfigured) + })) + nextState.llmForm = syncLegacyModelFieldsFromRows({ + ...nextState.llmForm, + models: authoritativeModels + }) + } + + for (const [formKey, statusKey] of [ + ['adminForm', 'adminPasswordConfigured'], + ['renderForm', 'jwtSecretConfigured'], + ['mailForm', 'passwordConfigured'] + ]) { + if (Object.hasOwn(snapshot?.[formKey] || {}, statusKey)) { + nextState[formKey][statusKey] = Boolean(snapshot[formKey][statusKey]) + } + } + + return nextState +} + export function sanitizeForStorage(state) { return { companyForm: { ...state.companyForm }, diff --git a/web/tests/settings-secret-status.test.mjs b/web/tests/settings-secret-status.test.mjs new file mode 100644 index 0000000..3cc020d --- /dev/null +++ b/web/tests/settings-secret-status.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + applyAuthoritativeSecretStatus +} from '../src/utils/settingsModelHelper.js' + +test('server secret status overrides stale configured flags from the session draft', () => { + const modelRows = [ + { + slot: 'main', + provider: 'Ali', + url: 'https://example.com', + apiKey: 'unsaved-main-key', + modelId: 'main', + type: 'llm' + }, + { slot: 'backup', provider: 'GLM', url: 'https://example.com', modelId: 'backup', type: 'llm' }, + { slot: 'embedding', provider: 'GLM', url: 'https://example.com', modelId: 'embedding', type: 'embedding' }, + { slot: 'reranker', provider: 'Ali', url: 'https://example.com', modelId: 'reranker', type: 'rerank' } + ] + const staleDraft = { + companyForm: {}, + appearanceForm: {}, + adminForm: { adminPasswordConfigured: true }, + sessionForm: {}, + hermesForm: {}, + llmForm: { + embeddingApiKeyConfigured: true, + rerankerApiKeyConfigured: true, + models: [ + ...modelRows.map((model) => ({ + ...model, + apiKeyConfigured: true + })), + { + slot: 'server_deleted', + provider: 'Ali', + url: 'https://stale.example.com', + modelId: 'stale', + type: 'llm', + apiKeyConfigured: true + } + ] + }, + renderForm: { jwtSecretConfigured: true }, + logForm: {}, + mailForm: { passwordConfigured: true } + } + const serverSnapshot = { + adminForm: { adminPasswordConfigured: true }, + llmForm: { + mainApiKeyConfigured: true, + backupApiKeyConfigured: true, + embeddingApiKeyConfigured: false, + rerankerApiKeyConfigured: false, + models: [ + ...modelRows.map((model) => ({ + ...model, + apiKey: '', + apiKeyConfigured: ['main', 'backup'].includes(model.slot) + })), + { + slot: 'server_only', + provider: 'GLM', + url: 'https://server.example.com', + apiKey: '', + modelId: 'server-model', + type: 'llm', + apiKeyConfigured: true + } + ] + }, + renderForm: { jwtSecretConfigured: false }, + mailForm: { passwordConfigured: false } + } + + const resolved = applyAuthoritativeSecretStatus(staleDraft, serverSnapshot) + const configuredBySlot = new Map( + resolved.llmForm.models.map((model) => [model.slot, model.apiKeyConfigured]) + ) + + assert.equal(resolved.llmForm.mainApiKeyConfigured, true) + assert.equal(resolved.llmForm.embeddingApiKeyConfigured, false) + assert.equal(resolved.llmForm.rerankerApiKeyConfigured, false) + assert.equal(configuredBySlot.get('main'), true) + assert.equal(configuredBySlot.get('embedding'), false) + assert.equal(configuredBySlot.get('reranker'), false) + assert.equal(configuredBySlot.get('server_only'), true) + assert.equal(configuredBySlot.has('server_deleted'), false) + assert.equal( + resolved.llmForm.models.find((model) => model.slot === 'main')?.apiKey, + 'unsaved-main-key' + ) + assert.equal(resolved.llmForm.mainApiKey, 'unsaved-main-key') + assert.equal(resolved.adminForm.adminPasswordConfigured, true) + assert.equal(resolved.renderForm.jwtSecretConfigured, false) + assert.equal(resolved.mailForm.passwordConfigured, false) +})