diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 82b1f6bb2a..486c62d4c0 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -2,10 +2,10 @@ name: Desktop validation on: pull_request: - paths: ['desktop/**', 'src/powercontext/**', 'pyproject.toml', 'uv.lock', 'openapi/powercontext.yaml', 'tests/fixtures/transport_loopback_vectors.json', 'website/assets/**', '.github/workflows/desktop.yml'] + paths: ['desktop/**', 'src/powercontext/**', 'pyproject.toml', 'uv.lock', 'openapi/powercontext.yaml', 'tests/fixtures/transport_loopback_vectors.json', 'tests/test_desktop_fixture_qualification.py', 'website/assets/**', '.github/workflows/desktop.yml'] push: branches: [master, codex/desktop-preview] - paths: ['desktop/**', 'src/powercontext/**', 'pyproject.toml', 'uv.lock', 'openapi/powercontext.yaml', 'tests/fixtures/transport_loopback_vectors.json', 'website/assets/**', '.github/workflows/desktop.yml'] + paths: ['desktop/**', 'src/powercontext/**', 'pyproject.toml', 'uv.lock', 'openapi/powercontext.yaml', 'tests/fixtures/transport_loopback_vectors.json', 'tests/test_desktop_fixture_qualification.py', 'website/assets/**', '.github/workflows/desktop.yml'] workflow_dispatch: inputs: installer_run: @@ -71,16 +71,22 @@ jobs: - name: Build Server wheel for isolated acceptance working-directory: . run: uv build --wheel --out-dir desktop/.artifacts/server-wheel + - name: Qualify the disposable checkout and built-wheel fixture + working-directory: . + run: uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit $env:GITHUB_SHA - name: Check installed UI acceptance harness working-directory: . run: | - uv run --no-sync ruff check --no-fix desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py + uv run --no-sync ruff check --no-fix desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py desktop/tests/fixture_qualification.py if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - uv run --no-sync ruff format --check desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py + uv run --no-sync ruff format --check desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py desktop/tests/fixture_qualification.py if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - uv run --no-sync ty check desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py + uv run --no-sync ty check desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py desktop/tests/fixture_qualification.py if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - uv run --no-sync ty check --python-platform linux desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py + uv run --no-sync ty check --python-platform linux desktop/tests/installed_ui.py desktop/tests/installed_fixture.py desktop/tests/installed_workflow.py desktop/tests/fixture_qualification.py + - name: Check fixture qualification regressions + working-directory: . + run: uv run --no-sync python -m pytest tests/test_desktop_fixture_qualification.py - name: Build native API acceptance client run: cargo build --locked --manifest-path src-tauri/Cargo.toml --example server_probe --example diagnostic_cli_probe - name: Test native adapter with real SQLite Server @@ -89,8 +95,8 @@ jobs: - name: Test native diagnostics with isolated real CLI working-directory: . run: uv run --no-sync python desktop/tests/real_cli.py - - name: Build unsigned internal Windows installer - run: pnpm desktop:build + - name: Build fixture-qualified unsigned internal Windows installer + run: pnpm tauri build --bundles nsis --ci --features ci-fixtures -- --locked - name: Prepare matching WebView2 driver run: ./scripts/prepare-webdriver.ps1 - name: Smoke test installed package @@ -104,6 +110,7 @@ jobs: path: | desktop/src-tauri/target/release/bundle/nsis/*-setup.exe desktop/.artifacts/windows-smoke.json + desktop/.artifacts/ci-compatibility.json desktop/.artifacts/real-server.json desktop/.artifacts/real-cli.json desktop/.artifacts/installed-ui.json @@ -142,6 +149,8 @@ jobs: uv sync --locked --python 3.12 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } uv build --wheel --out-dir desktop/.artifacts/server-wheel + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit $env:GITHUB_SHA - name: Retrieve and verify exact installer env: GH_TOKEN: ${{ github.token }} diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 006d7f5023..aa5bb1c165 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -32,6 +32,19 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex This qualification used macOS 26.2 ARM64, Rust 1.95.0 and Python 3.12.7. An external system Python launcher was used for native process creation because this environment can terminate native subprocesses before their first request. The wheel, native client, fixtures and business assertions were preserved. The real CLI's fixed version, service-status and integration commands were also exercised with an isolated home and PATH; their actual outputs passed the native projections. These checks establish the stated SQLite compatibility and CLI response handling. Windows registered-executable checks, credentials, process containment and installed-package acceptance require their actual Windows CI checks. No remote legacy deployment was tested in this qualification. +## CI fixture 的独立资格 + +CI fixture 使用独立资格记录,不会修改历史生产资格。原生 HTTP 测试显式注入 synthetic profile;真实 Server 和安装验收使用 `fixture_qualification.py` 从当前合同、Server 提交和构建 wheel 生成的 `.artifacts/ci-compatibility.json`,读取时重新核对合同、操作集合和 wheel 哈希。 + +只有显式启用 `ci-fixtures` 的测试安装包会包含这个 profile。普通构建仍只使用历史资格记录,并保留合同不匹配时的 fail-closed 行为。CI fixture 和其安装包不能作为生产发布资格证据。 + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +DESKTOP_FIXTURE_COMMIT=$(git rev-parse HEAD) +uv run python desktop/tests/fixture_qualification.py --server-commit "$DESKTOP_FIXTURE_COMMIT" +pnpm --dir desktop tauri build --bundles nsis --ci --features ci-fixtures -- --locked +``` + ## Remaining release qualification - Clean standard-user Windows 11, including absent/present WebView2 and bootstrap recovery. diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 43df341e4c..4da000f307 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ name = "powercontext_desktop" [features] custom-protocol = ["tauri/custom-protocol"] +ci-fixtures = [] [build-dependencies] tauri-build = { version = "=2.6.0", features = [] } diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 39c942ee82..c58762dd9c 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -15,6 +15,15 @@ */ fn main() { + if std::env::var_os("CARGO_FEATURE_CI_FIXTURES").is_some() { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../.artifacts/ci-compatibility.json"); + println!("cargo:rerun-if-changed={}", manifest.display()); + let output = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + std::fs::copy(manifest, output.join("ci-compatibility.json")).expect( + "ci-fixtures requires explicit built-wheel qualification; run fixture_qualification.py", + ); + } // Integration-test executables also need ComCtl32 v6 for Tauri's menu imports. if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED"); diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index 98a07ee0e0..3f16465f60 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -18,7 +18,7 @@ use powercontext_desktop::{ connections::{ profiles::ProfileRepository, - session::{ConnectionManager, WriteStatus}, + session::{CompatibilityProfile, ConnectionManager, WriteStatus}, }, credentials::{Secret, WindowsVault}, error::SafeError, @@ -27,6 +27,7 @@ use powercontext_desktop::{ use serde::Deserialize; #[derive(Deserialize)] struct Fixture { + compatibility_profile: CompatibilityProfile, response_loss_path: Option, identity_change_path: Option, endpoint: String, @@ -115,31 +116,16 @@ async fn main() { assert_eq!(exact.text, text); // Exercise the same native context owner used by product IPC, not only bare HTTP adapters. let temporary = tempfile::tempdir().unwrap(); - let manager = ConnectionManager::new( + let manager = ConnectionManager::with_compatibility( ProfileRepository::open( temporary.path().join("profiles.json"), std::sync::Arc::new(WindowsVault), ) .unwrap(), + vec![fixture.compatibility_profile.clone()], ); let raw: serde_json::Value = serde_json::from_slice(&std::fs::read(&args[1]).unwrap()).unwrap(); - let contract: serde_json::Value = - serde_json::from_str(include_str!("../src/transport/operations.json")).unwrap(); - let contract_sha256 = contract["contractSha256"].as_str().unwrap(); - let state = manager.state().unwrap(); - let mut qualified = state - .compatibility_profiles - .iter() - .filter(|profile| profile.contract_sha256 == contract_sha256); - let compatibility = qualified - .next() - .expect("qualified fixture contract") - .id - .clone(); - assert!( - qualified.next().is_none(), - "ambiguous fixture qualification" - ); + let compatibility = &fixture.compatibility_profile.id; let credential = raw["token"] .as_str() .map(|value| serde_json::json!({"secret":value,"storage":"session_only"})); diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index 58e1ea2fbe..8f83f1c208 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -164,10 +164,31 @@ pub struct ConnectionManager { } impl ConnectionManager { pub fn new(profiles: ProfileRepository) -> Self { - let (changed, _) = watch::channel(0); // No configuration becomes qualified merely because a health probe returned 200. - let compatibility = serde_json::from_str(include_str!("compatibility.json")) - .expect("bundled compatibility manifest"); + let compatibility: Vec = + serde_json::from_str(include_str!("compatibility.json")) + .expect("bundled compatibility manifest"); + #[cfg(feature = "ci-fixtures")] + let compatibility = { + let mut compatibility = compatibility; + compatibility.push( + serde_json::from_str(include_str!(concat!( + env!("OUT_DIR"), + "/ci-compatibility.json" + ))) + .expect("explicit CI fixture qualification"), + ); + compatibility + }; + Self::with_compatibility(profiles, compatibility) + } + /// Supply explicit native qualification evidence, including synthetic test fixtures. + /// This constructor is never exposed through IPC or renderer configuration. + pub fn with_compatibility( + profiles: ProfileRepository, + compatibility: Vec, + ) -> Self { + let (changed, _) = watch::channel(0); Self { inner: Mutex::new(Inner { profiles, diff --git a/desktop/src-tauri/tests/common/mod.rs b/desktop/src-tauri/tests/common/mod.rs new file mode 100644 index 0000000000..3c6f05b453 --- /dev/null +++ b/desktop/src-tauri/tests/common/mod.rs @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use powercontext_desktop::connections::{ + profiles::ProfileRepository, + session::{CompatibilityProfile, ConnectionManager}, +}; +use sha2::{Digest, Sha256}; + +pub const PROFILE_ID: &str = "synthetic-native-fixture"; + +pub fn connection_manager(profiles: ProfileRepository) -> ConnectionManager { + let contract: serde_json::Value = + serde_json::from_str(include_str!("../../src/transport/operations.json")).unwrap(); + let profile = CompatibilityProfile { + id: PROFILE_ID.into(), + server_commit: "synthetic-http-fixture-not-a-server-release".into(), + contract_sha256: contract["contractSha256"].as_str().unwrap().into(), + artifact_sha256: format!("{:x}", Sha256::digest(include_bytes!("mod.rs"))), + operations: contract["operations"].as_object().unwrap().keys().cloned().collect(), + evidence: "Synthetic HTTP fixtures in tests/memory.rs and tests/session.rs; not release qualification".into(), + }; + ConnectionManager::with_compatibility(profiles, vec![profile]) +} diff --git a/desktop/src-tauri/tests/memory.rs b/desktop/src-tauri/tests/memory.rs index 61c56ccc10..ba074e4a36 100644 --- a/desktop/src-tauri/tests/memory.rs +++ b/desktop/src-tauri/tests/memory.rs @@ -14,6 +14,8 @@ * limitations under the License. */ +mod common; + use powercontext_desktop::{ connections::{ profiles::ProfileRepository, @@ -147,26 +149,10 @@ async fn setup() -> Fixture { } }); let dir = tempfile::tempdir().unwrap(); - let manager = Arc::new(ConnectionManager::new( + let manager = Arc::new(common::connection_manager( ProfileRepository::open(dir.path().join("profiles.json"), Arc::new(WindowsVault)).unwrap(), )); - let contract: serde_json::Value = - serde_json::from_str(include_str!("../src/transport/operations.json")).unwrap(); - let contract_sha256 = contract["contractSha256"].as_str().unwrap(); - let state = manager.state().unwrap(); - let mut qualified = state - .compatibility_profiles - .iter() - .filter(|profile| profile.contract_sha256 == contract_sha256); - let compatibility = qualified - .next() - .expect("qualified fixture contract") - .id - .clone(); - assert!( - qualified.next().is_none(), - "ambiguous fixture qualification" - ); + let compatibility = common::PROFILE_ID; let mut ids = vec![]; for name in ["A", "B"] { let state = manager.save_profile(serde_json::from_value(serde_json::json!({"id":null,"revision":null,"name":name,"endpoint":endpoint,"authentication":"unauthenticated_loopback","caPem":null,"compatibility":compatibility,"keepCredential":false,"credential":null})).unwrap()).unwrap(); diff --git a/desktop/src-tauri/tests/session.rs b/desktop/src-tauri/tests/session.rs index 1363ac883d..888a81d41c 100644 --- a/desktop/src-tauri/tests/session.rs +++ b/desktop/src-tauri/tests/session.rs @@ -14,6 +14,8 @@ * limitations under the License. */ +mod common; + use powercontext_desktop::{ connections::{profiles::ProfileRepository, session::ConnectionManager}, credentials::WindowsVault, @@ -108,23 +110,7 @@ async fn fixture() -> Fixture { } } fn add(manager: &ConnectionManager, name: &str, endpoint: &str) -> String { - let contract: serde_json::Value = - serde_json::from_str(include_str!("../src/transport/operations.json")).unwrap(); - let contract_sha256 = contract["contractSha256"].as_str().unwrap(); - let state = manager.state().unwrap(); - let mut qualified = state - .compatibility_profiles - .iter() - .filter(|profile| profile.contract_sha256 == contract_sha256); - let compatibility = qualified - .next() - .expect("qualified fixture contract") - .id - .clone(); - assert!( - qualified.next().is_none(), - "ambiguous fixture qualification" - ); + let compatibility = common::PROFILE_ID; let state = manager.save_profile(serde_json::from_value(serde_json::json!({ "id":null,"revision":null,"name":name,"endpoint":endpoint, "authentication":"unauthenticated_loopback","caPem":null,"compatibility":compatibility, @@ -139,10 +125,61 @@ fn add(manager: &ConnectionManager, name: &str, endpoint: &str) -> String { .clone() } fn manager(dir: &tempfile::TempDir) -> Arc { - Arc::new(ConnectionManager::new( + Arc::new(common::connection_manager( ProfileRepository::open(dir.path().join("profiles.json"), Arc::new(WindowsVault)).unwrap(), )) } +#[tokio::test] +async fn successful_probes_do_not_qualify_a_synthetic_profile_in_the_product_manager() { + let server = fixture().await; + let dir = tempfile::tempdir().unwrap(); + let manager = ConnectionManager::new( + ProfileRepository::open(dir.path().join("profiles.json"), Arc::new(WindowsVault)).unwrap(), + ); + let id = add(&manager, "Unqualified", &server.endpoint); + let state = manager.check(&id, true).await.unwrap(); + let active = state.active.unwrap(); + assert!(!active.report.compatibility_verified); + assert!(active.report.supported_operations.is_empty()); + assert_eq!( + manager + .scopes(active.generation, "", None) + .await + .err() + .unwrap() + .code, + SafeError::CompatibilityUnverified + ); +} + +#[tokio::test] +async fn explicit_fixture_evidence_with_a_different_contract_remains_unverified() { + let server = fixture().await; + let dir = tempfile::tempdir().unwrap(); + let qualified = common::connection_manager( + ProfileRepository::open(dir.path().join("qualified.json"), Arc::new(WindowsVault)).unwrap(), + ); + let mut evidence = qualified.state().unwrap().compatibility_profiles[0].clone(); + evidence.contract_sha256 = "0".repeat(64); + let manager = ConnectionManager::with_compatibility( + ProfileRepository::open(dir.path().join("profiles.json"), Arc::new(WindowsVault)).unwrap(), + vec![evidence], + ); + let id = add(&manager, "Mismatched", &server.endpoint); + let state = manager.check(&id, true).await.unwrap(); + let active = state.active.unwrap(); + assert!(!active.report.compatibility_verified); + assert_eq!( + manager + .scopes(active.generation, "", None) + .await + .err() + .unwrap() + .code, + SafeError::CompatibilityUnverified + ); +} + #[tokio::test] async fn checking_another_profile_does_not_activate_it_and_capability_denial_is_independent() { let server = fixture().await; diff --git a/desktop/tests/fixture_qualification.py b/desktop/tests/fixture_qualification.py new file mode 100644 index 0000000000..2f449b149b --- /dev/null +++ b/desktop/tests/fixture_qualification.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026 OceanBase. +# +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Qualify a disposable checkout and built wheel without changing release evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path +from typing import Any + +DESKTOP = Path(__file__).resolve().parents[1] +PROFILE_ID = "desktop-ci-fixture" + + +def build_profile(desktop: Path, server_commit: str) -> dict[str, Any]: + if not re.fullmatch(r"[0-9a-f]{40}", server_commit): + raise ValueError("fixture_server_commit_required") + wheels = list((desktop / ".artifacts/server-wheel").glob("*.whl")) + if len(wheels) != 1: + raise ValueError("fixture_server_wheel_count") + contract = json.loads((desktop / "src-tauri/src/transport/operations.json").read_text(encoding="utf-8")) + contract_digest = hashlib.sha256( + (desktop.parent / "openapi/powercontext.yaml").read_text(encoding="utf-8").encode("utf-8") + ).hexdigest() + if contract["contractSha256"] != contract_digest: + raise ValueError("fixture_generated_contract_drift") + return { + "id": PROFILE_ID, + "serverCommit": server_commit, + "contractSha256": contract_digest, + "artifactSha256": hashlib.sha256(wheels[0].read_bytes()).hexdigest(), + "operations": sorted(contract["operations"]), + "evidence": "Disposable checkout and built-wheel CI fixture; not production or release qualification", + } + + +def fixture_profile(desktop: Path = DESKTOP) -> dict[str, Any]: + profile = json.loads((desktop / ".artifacts/ci-compatibility.json").read_text(encoding="utf-8")) + if profile != build_profile(desktop, profile["serverCommit"]): + raise ValueError("fixture_qualification_mismatch") + return profile + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server-commit", required=True) + args = parser.parse_args() + profile = build_profile(DESKTOP, args.server_commit) + destination = DESKTOP / ".artifacts/ci-compatibility.json" + destination.write_text(json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/desktop/tests/installed_fixture.py b/desktop/tests/installed_fixture.py index 7242c24427..5d23ed0ae2 100644 --- a/desktop/tests/installed_fixture.py +++ b/desktop/tests/installed_fixture.py @@ -30,17 +30,12 @@ from pathlib import Path import httpx +from fixture_qualification import fixture_profile from real_server import HarnessFailure, control_pipe def fixture_compatibility_profile() -> str: - desktop = Path(__file__).resolve().parents[1] - contract = json.loads((desktop / "src-tauri/src/transport/operations.json").read_text(encoding="utf-8")) - profiles = json.loads((desktop / "src-tauri/src/connections/compatibility.json").read_text(encoding="utf-8")) - matches = [profile["id"] for profile in profiles if profile["contractSha256"] == contract["contractSha256"]] - if len(matches) != 1: - raise HarnessFailure("installed_server_compatibility_profile") - return matches[0] + return fixture_profile()["id"] def wait_ready(client: httpx.Client, process: subprocess.Popen[bytes]) -> None: diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index 34cab14191..ab6e27011b 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -111,7 +111,7 @@ def connect(self, name: str, endpoint: str) -> None: self.type("连接名称", name) self.type("Server 地址", endpoint) compatibility = fixture_compatibility_profile() - self.click(f"//label[normalize-space(text())='已验证兼容配置']/select/option[@value='{compatibility}']") + self.click(f"//label[normalize-space(text())='已验证兼容配置']/select//option[@value='{compatibility}']") self.button("保存配置") self.activate(name) diff --git a/desktop/tests/real_server.py b/desktop/tests/real_server.py index 09c6a85d95..617665aa86 100644 --- a/desktop/tests/real_server.py +++ b/desktop/tests/real_server.py @@ -227,6 +227,7 @@ def control_pipe(process: subprocess.Popen[bytes]) -> IO[bytes]: def main() -> None: import httpx + from fixture_qualification import fixture_profile root = Path(__file__).resolve().parents[2] desktop = root / "desktop" @@ -235,6 +236,7 @@ def main() -> None: if len(wheels) != 1: raise HarnessFailure("wheel_count") wheel = wheels[0] + compatibility = fixture_profile() executable = ( desktop / "src-tauri/target/debug/examples" / ("server_probe.exe" if os.name == "nt" else "server_probe") ) @@ -322,6 +324,7 @@ def main() -> None: result.raise_for_status() scope_id = result.json()["scope_id"] fixture = { + "compatibility_profile": compatibility, "response_loss_path": config["response_loss_path"], "reader_token": config["reader_token"] if config["provider"] else None, "identity_change_path": config["identity_change_path"] if config["provider"] else None, diff --git a/tests/test_desktop_fixture_qualification.py b/tests/test_desktop_fixture_qualification.py new file mode 100644 index 0000000000..f53948154d --- /dev/null +++ b/tests/test_desktop_fixture_qualification.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026 OceanBase. +# +# 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Disposable fixture qualification preserves release evidence and rejects drift.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def qualification(): + specification = importlib.util.spec_from_file_location( + "desktop_fixture_qualification", ROOT / "desktop/tests/fixture_qualification.py" + ) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +@pytest.fixture +def desktop(tmp_path: Path) -> Path: + desktop = tmp_path / "desktop" + (desktop / "tests").mkdir(parents=True) + shutil.copyfile(ROOT / "desktop/tests/fixture_qualification.py", desktop / "tests/fixture_qualification.py") + (desktop / ".artifacts/server-wheel").mkdir(parents=True) + (desktop / ".artifacts/server-wheel/powercontext-fixture.whl").write_bytes(b"synthetic fixture artifact") + (tmp_path / "openapi").mkdir() + contract = tmp_path / "openapi/powercontext.yaml" + contract.write_bytes(b"openapi: 3.0.3\n# Synthetic current contract\n") + transport = desktop / "src-tauri/src/transport" + transport.mkdir(parents=True) + (transport / "operations.json").write_text( + json.dumps({ + "contractSha256": hashlib.sha256(contract.read_bytes()).hexdigest(), + "operations": {"remember_memory": {"method": "POST", "path": "/v1/memory/remember"}}, + }), + encoding="utf-8", + ) + historical = desktop / "src-tauri/src/connections" + historical.mkdir() + shutil.copyfile(ROOT / "desktop/src-tauri/src/connections/compatibility.json", historical / "compatibility.json") + return desktop + + +def test_cli_qualifies_current_fixture_without_rewriting_historical_profiles(desktop: Path, qualification) -> None: + historical = desktop / "src-tauri/src/connections/compatibility.json" + before = historical.read_bytes() + result = subprocess.run( + [sys.executable, "-I", str(desktop / "tests/fixture_qualification.py"), "--server-commit", "a" * 40], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + profile = qualification.fixture_profile(desktop) + assert profile["id"] == "desktop-ci-fixture" + assert profile["serverCommit"] == "a" * 40 + assert profile["operations"] == ["remember_memory"] + assert profile["artifactSha256"] == hashlib.sha256(b"synthetic fixture artifact").hexdigest() + assert historical.read_bytes() == before + assert all(entry["contractSha256"] != profile["contractSha256"] for entry in json.loads(before)) + + +@pytest.mark.parametrize("changed", ["wheel", "contract", "operations"]) +def test_fixture_readback_rejects_changed_artifact_or_contract(desktop: Path, qualification, changed: str) -> None: + profile = qualification.build_profile(desktop, "a" * 40) + (desktop / ".artifacts/ci-compatibility.json").write_text(json.dumps(profile), encoding="utf-8") + if changed == "wheel": + (desktop / ".artifacts/server-wheel/powercontext-fixture.whl").write_bytes(b"replacement fixture artifact") + elif changed == "contract": + (desktop.parent / "openapi/powercontext.yaml").write_bytes(b"changed contract") + else: + transport = desktop / "src-tauri/src/transport/operations.json" + contract = json.loads(transport.read_text(encoding="utf-8")) + contract["operations"]["search_memory"] = {"method": "POST", "path": "/v1/memory/search"} + transport.write_text(json.dumps(contract), encoding="utf-8") + with pytest.raises(ValueError, match=r"fixture_(qualification_mismatch|generated_contract_drift)"): + qualification.fixture_profile(desktop) + + +def test_stale_generated_contract_cannot_create_fixture_qualification(desktop: Path, qualification) -> None: + (desktop.parent / "openapi/powercontext.yaml").write_bytes(b"new contract") + with pytest.raises(ValueError, match="fixture_generated_contract_drift"): + qualification.build_profile(desktop, "a" * 40) + assert not (desktop / ".artifacts/ci-compatibility.json").exists()