Merge pull request #2806 from langbot-app/ci/daily-api-contract

ci(daily): add API contract regression job
This commit is contained in:
huanghuoguoguo
2026-10-29 11:10:46 +08:00
committed by GitHub
12 changed files with 1119 additions and 15 deletions
+91
View File
@@ -158,3 +158,94 @@ jobs:
.qa/daily/*.log
if-no-files-found: warn
retention-days: 30
api-contracts:
name: Daily API contracts
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install locked dependencies
run: uv sync --dev --frozen
- name: Prepare CLI and sandbox image
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p .api-bin
gh release download v0.3.0 --repo langbot-app/langbot-cli --dir .api-bin \
--pattern lbctl_linux_amd64 --pattern checksums.txt
cd .api-bin
sha256sum --check --ignore-missing checksums.txt
chmod +x lbctl_linux_amd64
docker pull python:3.12-alpine
- name: Run API contracts
run: |
uv run --no-sync python tools/regression_ci.py \
--suite api --api-seed "$GITHUB_RUN_ID" \
--lbctl .api-bin/lbctl_linux_amd64 --plugins plugins --output .qa/api
- name: Publish API result
if: always()
run: |
python - <<'PY_SUMMARY'
import json, os
from pathlib import Path
root = Path('.qa/api')
def read(path):
source = root / path
return json.loads(source.read_text()) if source.exists() else {}
gate = read('gate/gate.json')
suite = read('gate/suite/summary.json')
coverage = read('gate/suite/coverage.json')
manifest = read('plugins/manifest.json')
passed = gate.get('passed') is True and suite.get('passed') is True
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out:
out.write(f"Daily API contracts: {'PASS' if passed else 'FAIL / incomplete'}\n\n")
out.write(f"Core: `{os.environ['GITHUB_SHA']}`; shuffle seed: `{gate.get('api_seed', os.environ['GITHUB_RUN_ID'])}`.\n\n")
out.write('Fixed order followed by a seeded chain shuffle, with sequential operations inside each chain. The execution guard is 600 seconds; dependency and service preparation is separate. This job is not a required PR check.\n\n')
out.write(f"Cases: {suite.get('requests', 'not reported')}; statuses: {suite.get('status', 'not reported')}; suite execution: {suite.get('elapsed_seconds', 'not reported')}s.\n\n")
out.write(f"API behavior contracts: {coverage.get('areas', {}).get('api', 'not reported')}.\n\n")
out.write(f"Observed HTTP route templates (including preparation): {len(coverage['http_observations']['routes']) if 'http_observations' in coverage else 'not reported'}. A route hit does not establish all behavior behind it.\n\n")
for plugin in manifest.get('plugins', []):
out.write(f"Plugin: `{plugin['id']}@{plugin['version']}`; installation readback: `{plugin.get('installed', 'not confirmed')}`.\n\n")
errors = list(suite.get('errors', [])) + list(gate.get('resource_errors', [])) + list(gate.get('coverage_errors', []))
if gate.get('timed_out'):
errors.append('API suite exceeded its 600-second outer process guard.')
if gate.get('infrastructure_error'):
errors.append(gate['infrastructure_error'])
if not gate or not suite:
errors.append('Missing verdict; inspect preparation and service logs.')
for error in errors:
out.write(f"- {error}\n")
PY_SUMMARY
- name: Preserve API verdicts and evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: daily-api-${{ github.run_id }}-${{ github.run_attempt }}
path: |
.qa/api/gate/gate.json
.qa/api/gate/resources.jsonl
.qa/api/gate/suite.log
.qa/api/gate/suite/summary.json
.qa/api/gate/suite/profile.json
.qa/api/gate/suite/cases.json
.qa/api/gate/suite/health.json
.qa/api/gate/suite/cleanup-checks.json
.qa/api/gate/suite/resource-case-*.json
.qa/api/gate/suite/coverage.json
.qa/api/gate/suite/coverage.md
.qa/api/plugins/manifest.json
.qa/api/plugins/*-build.log
.qa/api/*.log
if-no-files-found: warn
retention-days: 30
+41 -1
View File
@@ -486,7 +486,7 @@ uv run --no-sync python tools/regression_ci.py --suite daily --restart-services
`--suite smoke` retains the 60-second gate. `--suite scenarios` runs the shared
expanded profile once under a 300-second guard. `--suite endurance` retains the
separate ten-minute load profile under a 900-second guard; its CI entry point also
reports throughput/P95 without applying their thresholds. All four modes use
reports throughput/P95 without applying their thresholds. All suites use
published plugins when `--plugins` is omitted. Both workflows explicitly select
`--plugins plugins` so source changes are tested in the same PR. Daily additionally
selects `misc/EssentialCommands misc/FAQManager`; manual dispatch can change the
@@ -498,6 +498,46 @@ bind their own extensions to avoid changing unrelated prompts/tools. The suite a
even after failures. The CI owner then stops its service processes and removes
its Box containers; a process guard timeout can interrupt API cleanup.
## Daily API contract job
`daily-endurance.yml` runs API contracts alongside the 30-minute endurance job
on separate runners, at the existing daily schedule and on manual dispatch.
The API job uses the GitHub run ID as its shuffle seed, so each daily run explores
a different order and a rerun keeps the same seed. It has a 15-minute job timeout,
including preparation, and is not a required PR check. Its summary and separate
`daily-api-<run_id>-<attempt>` artifact retain failures, order, coverage and cleanup
evidence for 30 days.
To replay locally on a disposable Linux/Docker host, pass the recorded seed:
```sh
uv run --no-sync python tools/regression_ci.py --suite api --api-seed 20261028 \
--lbctl /path/to/lbctl --plugins plugins --output .qa/api-001
```
This separate suite executes seven independent operation chains once in fixed
order, then once in seed-shuffled order (14 case rows). Operations within each
chain retain their create/read/update/delete dependencies. Reuse the seed to
replay chain ordering; resource names and credentials remain unique. Preparation
is outside the 600-second execution guard; a completed run ends immediately.
The default PR smoke and daily scenario groups do not include these cases.
The chains cover Provider and LLM/embedding/rerank model CRUD, Pipeline extension
validation, API key permissions/revocation, Skill files and revision conflicts,
and disabled MCP configuration. Checks require matching HTTP/business status,
read back stored state, and reject unexpected mutation after invalid requests.
Resources carry unique ownership markers and are deleted in `finally`; API keys
are revoked. Cleanup errors remain failures. MCP fixtures stay disabled and use
loopback addresses. This job does not exercise external MCP servers, real model
inference, concurrent API mutations, or cross-Workspace isolation.
Inspect `gate/gate.json` for the verdict, seed and exact case order;
`gate/suite/cases.json` for HTTP steps, assertions and cleanup results; and
`gate/suite/coverage.json` for observed route templates and behavior contracts.
An HTTP route hit includes rejected requests and is not proof of all behavior
behind that route. Invalid MCP modes must return HTTP 400 and leave the original
configuration unchanged. Any failed contract or cleanup makes the job fail.
## Sustained regression and baseline comparison
[`regression_profiles/endurance.json`](regression_profiles/endurance.json) runs
+213
View File
@@ -0,0 +1,213 @@
"""Finite API contracts: fixed first pass, then reproducibly shuffled independent chains."""
import json
import random
from regression_api_checks import check, owned_resource, request, tracked_resource, unique_name, user_headers
from regression_coverage import checked
from regression_local_cases import require
from regression_resource_cases import cleanup, journal
CASE_NAMES = ('api_llm', 'api_embedding', 'api_rerank', 'api_pipeline', 'api_credentials', 'api_skills', 'api_mcp')
def add_cases(profile):
require(profile.get('coverage_suite') == 'api', 'API contracts require their dedicated suite')
shuffled = list(CASE_NAMES)
seed = profile['vars']['api_seed']
random.Random(seed).shuffle(shuffled)
for phase, names in (('fixed', CASE_NAMES), ('shuffled', shuffled)):
for name in names:
profile['cases'].append(
{'name': f'{phase}_{name}', 'scenario': name, 'stage': 'api', 'expect': 'API_CONTRACT_OK'}
)
profile['scenario_groups'] = ['api']
def model_case(suite, row, model_type):
providers = '/api/v1/provider/providers'
name, secret = unique_name(), unique_name()
suite.secrets.append(secret)
body = {
'name': name,
'requester': 'jina-rerank' if model_type == 'rerank' else 'openai-chat-completions',
'base_url': suite.variables['mock_base_url'],
'api_keys': [secret],
}
with owned_resource(suite, row, providers, body, item_key='provider', list_key='providers') as (path, uid):
visible = request(suite, row, 'GET', path + '?include_secret=true')['provider']
check(
row,
'provider_identity',
visible['name'] == name and visible['api_keys'] == [secret],
'Provider identity or secret changed',
)
redacted = request(suite, row, 'GET', path + '?include_secret=false')['provider']
check(row, 'provider_redacted', secret not in json.dumps(redacted), 'Provider secret was disclosed')
request(suite, row, 'GET', path + '?include_secret=invalid', expected=400)
request(suite, row, 'PUT', path, {'name': name + '-updated'})
updated = request(suite, row, 'GET', path + '?include_secret=true')['provider']
check(
row,
'provider_update',
updated['name'] == name + '-updated' and updated['api_keys'] == [secret],
'Provider metadata update lost its secret',
)
collection = '/api/v1/provider/models/' + model_type
model_name = unique_name()
with owned_resource(
suite, row, collection, {'name': model_name, 'provider_uuid': uid}, item_key='model', list_key='models'
) as (model_path, model_id):
model = request(suite, row, 'GET', model_path)['model']
check(
row,
'model_identity',
model['name'] == model_name and model['provider_uuid'] == uid,
'Model did not retain its provider binding',
)
items = request(suite, row, 'GET', collection + '?provider_uuid=' + uid)['models']
check(row, 'model_list', [m['uuid'] for m in items] == [model_id], 'Provider model filter is incorrect')
request(suite, row, 'PUT', model_path, {'name': model_name + '-updated'})
model = request(suite, row, 'GET', model_path)['model']
check(
row,
'model_update',
model['name'] == model_name + '-updated' and model['provider_uuid'] == uid,
'Model metadata update lost its binding',
)
request(suite, row, 'DELETE', path, expected=400)
still = request(suite, row, 'GET', model_path)['model']
check(row, 'rejected_delete_preserves_model', still == model, 'Rejected provider deletion mutated child')
request(suite, row, 'GET', path)
items = request(suite, row, 'GET', collection + '?provider_uuid=' + uid)['models']
check(row, 'model_deleted_from_list', not items, 'Deleted model remains listed')
def pipeline_case(suite, row):
collection, name = '/api/v1/pipelines', unique_name()
with owned_resource(suite, row, collection, {'name': name}, item_key='pipeline', list_key='pipelines') as (
path,
uid,
):
pipeline = request(suite, row, 'GET', path)['pipeline']
check(
row,
'pipeline_identity',
pipeline['name'] == name and pipeline['uuid'] == uid,
'Pipeline creation readback differs',
)
items = request(suite, row, 'GET', collection)['pipelines']
check(row, 'pipeline_list', sum(p['uuid'] == uid for p in items) == 1, 'Pipeline missing or duplicated')
request(suite, row, 'PUT', path, {'name': name + '-updated', 'description': 'API contract 中文'})
updated = request(suite, row, 'GET', path)['pipeline']
check(
row,
'pipeline_update',
updated['name'] == name + '-updated' and updated['description'] == 'API contract 中文',
'Pipeline update readback differs',
)
bindings = dict(
bound_plugins=[],
bound_mcp_servers=[],
bound_skills=[],
enable_all_plugins=False,
enable_all_mcp_servers=False,
enable_all_skills=False,
)
request(suite, row, 'PUT', path + '/extensions', bindings)
before = request(suite, row, 'GET', path + '/extensions')
check(row, 'extension_readback', all(before[k] == v for k, v in bindings.items()), 'Extension bindings differ')
for invalid in ({'enable_all_plugins': 'false'}, {'bound_skills': [42]}, {'bound_plugins': [{}]}):
request(suite, row, 'PUT', path + '/extensions', invalid, expected=400)
after = request(suite, row, 'GET', path + '/extensions')
check(row, 'rejected_extension_' + next(iter(invalid)), after == before, 'Rejected binding changed state')
for invalid in ({}, {'message': ''}, {'message': 42}):
request(suite, row, 'POST', path + '/run', invalid, expected=400)
after = request(suite, row, 'GET', path)['pipeline']
check(row, 'invalid_run_preserves_config', after['config'] == updated['config'], 'Invalid run changed config')
def credentials_case(suite, row):
manager, collection, name = user_headers(suite), '/api/v1/apikeys', unique_name()
for headers in ({}, {'Authorization': 'Bearer qa-invalid'}):
request(suite, row, 'GET', '/api/v1/pipelines', headers=headers, expected=401)
owned = {'kind': 'api-key', 'name': name, 'state': 'creating'}
row.setdefault('owned_resources', []).append(owned)
journal(suite, row)
def revoke():
if 'id' not in owned:
keys = request(suite, row, 'GET', collection, headers=manager)['keys']
matches = [k for k in keys if k['name'] in (name, name + '-updated')]
require(len(matches) <= 1, 'Ambiguous owned key')
if not matches:
owned['state'] = 'absent'
return
owned['id'] = matches[0]['id']
path = collection + '/' + str(owned['id'])
current = request(suite, row, 'GET', path, headers=manager)['key']
require(current['name'] in (name, name + '-updated'), 'Key ownership changed')
if current['status'] != 'revoked':
request(suite, row, 'DELETE', path, headers=manager)
current = request(suite, row, 'GET', path, headers=manager)['key']
check(
row,
'revoked_readback',
current['status'] == 'revoked' and current['secret_available'] is False and 'key' not in current,
'Revoked key remains active or exposes its secret',
)
owned['state'] = 'revoked'
try:
before = request(suite, row, 'GET', collection, headers=manager)['keys']
for invalid in ({'scopes': ['qa.invalid']}, {'expires_at': 'not-a-date'}):
request(suite, row, 'POST', collection, {'name': name, **invalid}, headers=manager, expected=400)
after = request(suite, row, 'GET', collection, headers=manager)['keys']
check(row, 'invalid_key_not_created', before == after, 'Invalid key request changed key list')
key = request(suite, row, 'POST', collection, {'name': name, 'scopes': ['resource.view']}, headers=manager)[
'key'
]
owned.update(id=key['id'], state='active')
journal(suite, row)
secret = key['key']
suite.secrets.append(secret)
child = {'X-API-Key': secret}
path = collection + '/' + str(key['id'])
request(suite, row, 'PUT', path, {'name': name + '-updated'}, headers=manager)
current = request(suite, row, 'GET', path, headers=manager)['key']
check(
row,
'key_metadata_redacted',
current['name'] == name + '-updated' and 'key' not in current,
'Key metadata update or one-time-secret contract failed',
)
with tracked_resource(suite, row, '/api/v1/pipelines', name, item_key='pipeline', list_key='pipelines'):
before = request(suite, row, 'GET', '/api/v1/pipelines', headers=child)['pipelines']
request(suite, row, 'POST', '/api/v1/pipelines', {'name': name}, headers=child, expected=403)
after = request(suite, row, 'GET', '/api/v1/pipelines', headers=child)['pipelines']
check(row, 'readonly_denial_preserves_state', before == after, 'Read-only key changed resources')
revoke()
request(suite, row, 'GET', '/api/v1/pipelines', headers=child, expected=401)
finally:
cleanup(suite, row, 'key_cleanup', revoke)
def run_case(suite, case, row):
from regression_api_resources import mcp_case, skills_case
name = case['scenario']
row['api_seed'] = suite.profile['vars']['api_seed']
def execute():
if name in ('api_llm', 'api_embedding', 'api_rerank'):
model_case(suite, row, name.removeprefix('api_'))
else:
{
'api_pipeline': pipeline_case,
'api_credentials': credentials_case,
'api_skills': skills_case,
'api_mcp': mcp_case,
}[name](suite, row)
checked(row, 'api_chain', execute)
return {'result': case['expect']}
+84
View File
@@ -0,0 +1,84 @@
"""HTTP and ownership checks shared by the local API contract cases."""
from contextlib import contextmanager
from uuid import uuid4
from regression_coverage import checked
from regression_local_cases import api, require
from regression_resource_cases import cleanup, journal
def check(row, name, condition, message):
return checked(row, name, lambda: require(condition, message))
def request(suite, row, method, path, body=None, *, expected=200, headers=None):
step = {'method': method, 'path': path, 'expected_status': expected}
row.setdefault('http_steps', []).append(step)
status, response = api(suite, method, path, body, headers=headers)
step['status'] = status
check(
row,
f'http_{len(row["http_steps"])}',
status == expected
and isinstance(response, dict)
and ((response.get('code') == 0) if expected < 400 else (response.get('code') not in (None, 0))),
f'{method} {path}: expected HTTP {expected} with matching business status, got {status}',
)
return response.get('data', {}) if expected < 400 else response
def unique_name():
return 'qa-api-' + uuid4().hex
def user_headers(suite):
token = suite.env.get('LANGBOT_QA_USER_TOKEN')
require(bool(token), 'API contracts require the disposable test account token')
if token not in suite.secrets:
suite.secrets.append(token)
return {'Authorization': 'Bearer ' + token}
@contextmanager
def tracked_resource(suite, row, collection, name, *, item_key, list_key):
"""Clean an owned UUID resource even if its creation unexpectedly succeeds or times out."""
owned = {'kind': item_key, 'name': name, 'state': 'creating'}
row.setdefault('owned_resources', []).append(owned)
journal(suite, row)
def remove():
if 'uuid' not in owned:
items = request(suite, row, 'GET', collection)[list_key]
matches = [item for item in items if item.get('name') in (name, name + '-updated')]
require(len(matches) <= 1, 'Ambiguous owned resource identity')
if not matches:
owned['state'] = 'absent'
return
owned['uuid'] = matches[0]['uuid']
path = collection + '/' + owned['uuid']
status, response = api(suite, 'GET', path)
if status != 404:
require(status == 200 and response.get('code') == 0, 'Cannot read resource before cleanup')
item = response['data'][item_key]
require(
item.get('uuid') == owned['uuid'] and item.get('name') in (name, name + '-updated'),
'Resource ownership changed before cleanup',
)
request(suite, row, 'DELETE', path)
request(suite, row, 'GET', path, expected=404)
owned['state'] = 'deleted'
try:
yield owned
finally:
cleanup(suite, row, 'cleanup_' + item_key + '_' + name, remove)
@contextmanager
def owned_resource(suite, row, collection, body, *, item_key, list_key):
with tracked_resource(suite, row, collection, body['name'], item_key=item_key, list_key=list_key) as owned:
result = request(suite, row, 'POST', collection, body)
owned.update(uuid=result['uuid'], state='created')
journal(suite, row)
yield collection + '/' + owned['uuid'], owned['uuid']
+310
View File
@@ -0,0 +1,310 @@
"""Local Skill publications and disabled MCP configuration HTTP contracts."""
from contextlib import contextmanager
from regression_api_checks import check, request, unique_name, user_headers
from regression_local_cases import api, require
from regression_resource_cases import cleanup, journal
@contextmanager
def _named_resource(suite, row, collection, body, item_key, ownership_field, headers):
name = body['name']
path = collection + '/' + name
request(suite, row, 'GET', path, expected=404, headers=headers)
owned = {'kind': item_key, 'name': name, 'state': 'creating'}
row.setdefault('owned_resources', []).append(owned)
journal(suite, row)
def remove():
status, response = api(suite, 'GET', path, headers=headers)
if status != 404:
require(status == 200 and response.get('code') == 0, 'Cannot read owned resource before cleanup')
item = response['data'][item_key]
require(
item.get('name') == name and item.get(ownership_field) == body[ownership_field],
'Named resource ownership changed before cleanup',
)
if 'uuid' in owned:
require(item.get('uuid') == owned['uuid'], 'Named resource identity changed before cleanup')
request(suite, row, 'DELETE', path, headers=headers)
request(suite, row, 'GET', path, expected=404, headers=headers)
owned['state'] = 'deleted'
try:
created = request(suite, row, 'POST', collection, body, headers=headers)
owned['state'] = 'created'
if created.get('uuid'):
owned['uuid'] = created['uuid']
journal(suite, row)
yield path, created
finally:
cleanup(suite, row, 'cleanup_' + item_key, remove)
def _expect_rejection(suite, row, method, path, body, *, expected, headers, verify):
try:
request(suite, row, method, path, body, expected=expected, headers=headers)
except Exception:
try:
verify()
except Exception:
# Both assertions are recorded; retain the original HTTP failure.
pass
raise
verify()
def skills_case(suite, row):
headers = user_headers(suite)
name = unique_name()
instructions = '# API fixture\n\nRead the local reference file.'
body = {
'name': name,
'display_name': 'API contract fixture',
'description': 'Owned Skill fixture ' + name,
'instructions': instructions,
}
with _named_resource(suite, row, '/api/v1/skills', body, 'skill', 'description', headers) as (path, created):
skill = created['skill']
initial_revision = skill.get('revision')
check(
row,
'skill_created',
skill.get('name') == name and bool(initial_revision),
'Skill identity/revision missing',
)
readback = request(suite, row, 'GET', path, headers=headers)['skill']
check(row, 'skill_readback', readback == skill, 'Created Skill differs from its readback')
listed = request(suite, row, 'GET', '/api/v1/skills', headers=headers)['skills']
check(row, 'skill_listed', sum(item.get('name') == name for item in listed) == 1, 'Skill listing is not unique')
preview = request(suite, row, 'GET', path + '/preview', headers=headers)
check(row, 'skill_preview', preview.get('instructions') == instructions, 'Skill preview lost instructions')
file_path = path + '/files/references/fixture.txt'
content = 'Skill API fixture: 中文\n' + name + '\n'
written = request(
suite, row, 'PUT', file_path, {'content': content, 'base_revision': initial_revision}, headers=headers
)
file_revision = written.get('revision')
check(
row,
'skill_file_published',
bool(file_revision)
and file_revision != initial_revision
and written.get('bytes_written') == len(content.encode()),
'Skill file write did not publish the expected UTF-8 bytes as a new revision',
)
file_readback = request(suite, row, 'GET', file_path, headers=headers)
check(
row,
'skill_file_readback',
file_readback.get('content') == content
and file_readback.get('path') == 'references/fixture.txt'
and file_readback.get('skill', {}).get('name') == name,
'Skill file readback differs from the written content or identity',
)
files = request(suite, row, 'GET', path + '/files?path=references', headers=headers)
check(
row,
'skill_files_listed',
any(
item.get('path') == 'references/fixture.txt'
and item.get('size') == len(content.encode())
and item.get('is_dir') is False
for item in files.get('entries', [])
),
'Written Skill file is missing from the directory listing',
)
updated_instructions = '# Updated API fixture\n\nKeep the local reference file.'
updated = request(
suite,
row,
'PUT',
path,
{'instructions': updated_instructions, 'base_revision': file_revision},
headers=headers,
)['skill']
check(
row,
'skill_updated',
updated.get('instructions') == updated_instructions and updated.get('revision') != file_revision,
'Skill update did not publish changed instructions',
)
preview = request(suite, row, 'GET', path + '/preview', headers=headers)
check(
row, 'skill_updated_preview', preview.get('instructions') == updated_instructions, 'Skill preview is stale'
)
unchanged = request(
suite,
row,
'PUT',
path,
{'instructions': updated_instructions, 'base_revision': updated['revision']},
headers=headers,
)['skill']
check(
row,
'skill_update_idempotent',
unchanged.get('revision') == updated['revision'],
'No-op Skill update changed content revision',
)
before_conflict = request(suite, row, 'GET', path, headers=headers)['skill']
def verify_stale_update():
after_conflict = request(suite, row, 'GET', path, headers=headers)['skill']
check(
row,
'skill_stale_update_preserved',
after_conflict == before_conflict,
'Rejected Skill update changed the published state',
)
_expect_rejection(
suite,
row,
'PUT',
path,
{'instructions': 'stale metadata must not win', 'base_revision': initial_revision},
expected=409,
headers=headers,
verify=verify_stale_update,
)
before_file_conflict = request(suite, row, 'GET', file_path, headers=headers)
before_file_skill = request(suite, row, 'GET', path, headers=headers)['skill']
def verify_stale_file():
after_file_conflict = request(suite, row, 'GET', file_path, headers=headers)
after_conflict = request(suite, row, 'GET', path, headers=headers)['skill']
check(
row,
'skill_stale_file_preserved',
after_file_conflict == before_file_conflict and after_conflict == before_file_skill,
'Rejected Skill file write changed the file or published revision',
)
# The file route currently maps revision conflicts to HTTP 400.
_expect_rejection(
suite,
row,
'PUT',
file_path,
{'content': 'stale file must not win', 'base_revision': file_revision},
expected=400,
headers=headers,
verify=verify_stale_file,
)
request(suite, row, 'DELETE', path, headers=headers)
request(suite, row, 'GET', path, expected=404, headers=headers)
request(suite, row, 'DELETE', path, expected=400, headers=headers)
request(suite, row, 'GET', path, expected=404, headers=headers)
check(
row,
'skill_deleted',
not any(
item.get('name') == name
for item in request(suite, row, 'GET', '/api/v1/skills', headers=headers)['skills']
),
'Deleted Skill remains listed',
)
return {'skill_name': name, 'revision': before_conflict['revision']}
def _mcp_config(server):
return {
key: server.get(key) for key in ('uuid', 'workspace_uuid', 'name', 'enable', 'mode', 'extra_args', 'readme')
}
def mcp_case(suite, row):
headers = user_headers(suite)
name = unique_name()
collection = '/api/v1/mcp/servers'
body = {
'name': name,
'enable': False,
'mode': 'remote',
'extra_args': {'url': 'http://127.0.0.1:9/mcp'},
'readme': 'Owned disabled MCP fixture ' + name,
}
with _named_resource(suite, row, collection, body, 'server', 'readme', headers) as (path, created):
server = request(suite, row, 'GET', path, headers=headers)['server']
check(
row,
'mcp_created_disabled',
bool(created.get('uuid'))
and server.get('uuid') == created['uuid']
and server.get('workspace_uuid') == suite.variables['workspace_uuid']
and all(server.get(key) == value for key, value in body.items()),
'MCP create/readback changed identity, disabled state or configuration',
)
servers = request(suite, row, 'GET', collection, headers=headers)['servers']
check(
row,
'mcp_listed',
sum(item.get('name') == name and item.get('uuid') == created['uuid'] for item in servers) == 1,
'MCP listing did not return the created server exactly once',
)
update = {'enable': False, 'extra_args': {'url': 'http://127.0.0.1:9/updated-mcp'}}
request(suite, row, 'PUT', path, update, headers=headers)
updated = request(suite, row, 'GET', path, headers=headers)['server']
check(
row,
'mcp_updated_disabled',
_mcp_config(updated) == {**_mcp_config(server), **update},
'MCP update changed unrelated configuration or enabled the server',
)
request(suite, row, 'PUT', path, update, headers=headers)
repeated = request(suite, row, 'GET', path, headers=headers)['server']
check(
row,
'mcp_update_idempotent',
_mcp_config(repeated) == _mcp_config(updated),
'Repeated MCP update changed configuration',
)
def verify_duplicate():
after_duplicate = request(suite, row, 'GET', path, headers=headers)['server']
servers = request(suite, row, 'GET', collection, headers=headers)['servers']
check(
row,
'mcp_duplicate_preserved',
_mcp_config(after_duplicate) == _mcp_config(updated)
and sum(item.get('name') == name for item in servers) == 1,
'Duplicate MCP create changed the original or added another server',
)
_expect_rejection(suite, row, 'POST', collection, body, expected=400, headers=headers, verify=verify_duplicate)
def verify_invalid_transport():
after_invalid = request(suite, row, 'GET', path, headers=headers)['server']
row['mcp_transport'] = {'before': updated['mode'], 'after': after_invalid['mode']}
check(
row,
'mcp_invalid_transport_preserved',
_mcp_config(after_invalid) == _mcp_config(updated),
'Invalid MCP transport changed stored configuration',
)
_expect_rejection(
suite,
row,
'PUT',
path,
{'mode': 'qa-invalid-transport', 'enable': False},
expected=400,
headers=headers,
verify=verify_invalid_transport,
)
request(suite, row, 'DELETE', path, headers=headers)
request(suite, row, 'GET', path, expected=404, headers=headers)
request(suite, row, 'DELETE', path, expected=404, headers=headers)
servers = request(suite, row, 'GET', collection, headers=headers)['servers']
check(
row,
'mcp_deleted',
not any(item.get('name') == name for item in servers),
'Deleted MCP server remains listed',
)
return {'mcp_name': name, 'uuid': created['uuid']}
+19 -7
View File
@@ -16,17 +16,17 @@ import yaml
from regression_gate import supervise
from regression_coverage import write_report
from regression_plugins import PLUGIN_IDS, install_plugins, prepare_published_plugins, prepare_source_plugins
from regression_local_cases import GROUPS, api, require
from regression_local_cases import DEFAULT_GROUPS, api, require
from regression_suite import Suite
from regression_traffic import owned_box_containers, read_endpoint_snapshot
ROOT = Path(__file__).resolve().parents[1]
SUITE_BUDGETS = {'smoke': 60, 'scenarios': 300, 'daily': 1860, 'endurance': 900}
SUITE_BUDGETS = {'smoke': 60, 'scenarios': 300, 'daily': 1860, 'endurance': 900, 'api': 600}
sys.path.insert(0, str(ROOT))
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories # noqa: E402
def suite_profile(name, run_id, groups=()):
def suite_profile(name, run_id, groups=(), api_seed=1):
profile_path = ROOT / 'tools/regression_profiles' / ('endurance.json' if name == 'endurance' else 'smoke.json')
if name in ('scenarios', 'daily'):
from regression_scenarios import expanded_profile
@@ -34,6 +34,12 @@ def suite_profile(name, run_id, groups=()):
profile = expanded_profile(groups)
else:
profile = json.loads(profile_path.read_text())
if name == 'api':
from regression_api_cases import add_cases
profile.update(resources=[], cases=[], coverage_suite='api')
profile['vars']['api_seed'] = api_seed
add_cases(profile)
for resource in profile['resources']:
if resource['kind'] == 'pipeline':
resource['body'].setdefault(
@@ -172,10 +178,12 @@ def run(args):
config_path.write_text(yaml.safe_dump(config))
groups = args.scenario_groups
if groups is None:
groups = list(GROUPS) if args.suite in ('scenarios', 'daily') else []
groups = list(DEFAULT_GROUPS) if args.suite in ('scenarios', 'daily') else []
if args.suite == 'api':
groups = ['api']
if 'plugins' in groups and not {'misc/EssentialCommands', 'misc/FAQManager'} <= set(args.extra_plugins):
groups.remove('plugins')
profile, profile_path = suite_profile(args.suite, secrets.token_hex(8), groups)
profile, profile_path = suite_profile(args.suite, secrets.token_hex(8), groups, args.api_seed)
cli_config = private / 'lbctl.yaml'
cli_config.write_text(
yaml.safe_dump(
@@ -270,7 +278,7 @@ def run(args):
cli_data = yaml.safe_load(cli_config.read_text())
cli_data['contexts']['smoke']['expected_workspace_uuid'] = identity['workspace_uuid']
cli_config.write_text(yaml.safe_dump(cli_data))
if 'resources' in groups or args.restart_services:
if {'resources', 'api'} & set(groups) or args.restart_services:
# Account creation is restricted to this runner's newly created private database.
credentials = {'user': '[email protected]', 'password': secrets.token_urlsafe(32)}
suite.secrets.append(credentials['password'])
@@ -324,6 +332,9 @@ def run(args):
coverage = write_report(evidence, root / 'gate/suite', http_logs=[root / 'core.log'])
result['coverage_errors'] = coverage['evidence_errors']
result['scenario_groups'] = groups
if args.suite == 'api':
result['api_seed'] = args.api_seed
result['case_order'] = [case['name'] for case in profile['cases']]
result['passed'] = result['passed'] and not result['coverage_errors']
(root / 'gate/gate.json').write_text(json.dumps(result, indent=2) + '\n')
return result
@@ -357,9 +368,10 @@ def main():
parser.add_argument(
'--scenario-groups',
nargs='*',
choices=tuple(GROUPS),
choices=DEFAULT_GROUPS,
help='Local groups for scenarios/daily (default: all); an empty list selects only the base cases',
)
parser.add_argument('--api-seed', type=int, default=1, help='Replay the API suite chain shuffle (default: 1)')
parser.add_argument('--restart-services', action='store_true', help='Check owned Core/Runtime restarts once')
args = parser.parse_args()
if args.extra_plugins and args.plugins is None:
+56
View File
@@ -378,6 +378,62 @@
"area": "retrieval", "kind": "blackbox", "priority": "P1",
"description": "An empty knowledge base allows chat without injecting other knowledge fixtures",
"bindings": [{"profile": "scenarios", "case": "empty_rag_chat", "checks": ["response"]}]
},
{"id": "api.model_llm",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "llm model and provider CRUD preserve bindings, secrets and children on rejected deletion",
"bindings": [{"profile": "api", "case": "fixed_api_llm", "checks": ["api_chain", "provider_identity", "provider_redacted", "provider_update", "model_identity", "model_list", "model_update", "rejected_delete_preserves_model", "model_deleted_from_list"]}, {"profile": "api", "case": "shuffled_api_llm", "checks": ["api_chain", "provider_identity", "provider_redacted", "provider_update", "model_identity", "model_list", "model_update", "rejected_delete_preserves_model", "model_deleted_from_list"]}]
},
{"id": "api.model_embedding",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "embedding model and provider CRUD preserve bindings, secrets and children on rejected deletion",
"bindings": [{"profile": "api", "case": "fixed_api_embedding", "checks": ["api_chain", "provider_identity", "provider_redacted", "provider_update", "model_identity", "model_list", "model_update", "rejected_delete_preserves_model", "model_deleted_from_list"]}, {"profile": "api", "case": "shuffled_api_embedding", "checks": ["api_chain", "provider_identity", "provider_redacted", "provider_update", "model_identity", "model_list", "model_update", "rejected_delete_preserves_model", "model_deleted_from_list"]}]
},
{"id": "api.model_rerank",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "rerank model and provider CRUD preserve bindings, secrets and children on rejected deletion",
"bindings": [{"profile": "api", "case": "fixed_api_rerank", "checks": ["api_chain", "provider_identity", "provider_redacted", "provider_update", "model_identity", "model_list", "model_update", "rejected_delete_preserves_model", "model_deleted_from_list"]}, {"profile": "api", "case": "shuffled_api_rerank", "checks": ["api_chain", "provider_identity", "provider_redacted", "provider_update", "model_identity", "model_list", "model_update", "rejected_delete_preserves_model", "model_deleted_from_list"]}]
},
{"id": "api.pipeline",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "Pipeline CRUD and extension updates preserve state after invalid bindings and run inputs",
"bindings": [{"profile": "api", "case": "fixed_api_pipeline", "checks": ["api_chain", "pipeline_identity", "pipeline_list", "pipeline_update", "extension_readback", "rejected_extension_enable_all_plugins", "rejected_extension_bound_skills", "rejected_extension_bound_plugins", "invalid_run_preserves_config"]}, {"profile": "api", "case": "shuffled_api_pipeline", "checks": ["api_chain", "pipeline_identity", "pipeline_list", "pipeline_update", "extension_readback", "rejected_extension_enable_all_plugins", "rejected_extension_bound_skills", "rejected_extension_bound_plugins", "invalid_run_preserves_config"]}]
},
{"id": "api.credentials",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "Read-only and revoked keys cannot write or read protected resources; secrets are returned only once",
"bindings": [{"profile": "api", "case": "fixed_api_credentials", "checks": ["api_chain", "invalid_key_not_created", "key_metadata_redacted", "readonly_denial_preserves_state", "revoked_readback"]}, {"profile": "api", "case": "shuffled_api_credentials", "checks": ["api_chain", "invalid_key_not_created", "key_metadata_redacted", "readonly_denial_preserves_state", "revoked_readback"]}]
},
{"id": "api.skills",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "Skill publication and UTF-8 files retain revision and content after stale writes",
"bindings": [{"profile": "api", "case": "fixed_api_skills", "checks": ["api_chain", "skill_created", "skill_file_published", "skill_updated", "skill_update_idempotent", "skill_stale_update_preserved", "skill_stale_file_preserved", "skill_deleted"]}, {"profile": "api", "case": "shuffled_api_skills", "checks": ["api_chain", "skill_created", "skill_file_published", "skill_updated", "skill_update_idempotent", "skill_stale_update_preserved", "skill_stale_file_preserved", "skill_deleted"]}]
},
{"id": "api.mcp_crud",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "Disabled MCP configurations retain identity and state across CRUD and duplicate requests",
"bindings": [{"profile": "api", "case": "fixed_api_mcp", "checks": ["mcp_created_disabled", "mcp_listed", "mcp_updated_disabled", "mcp_update_idempotent", "mcp_duplicate_preserved", "cleanup_server"]}, {"profile": "api", "case": "shuffled_api_mcp", "checks": ["mcp_created_disabled", "mcp_listed", "mcp_updated_disabled", "mcp_update_idempotent", "mcp_duplicate_preserved", "cleanup_server"]}]
},
{"id": "api.mcp_validation",
"area": "api",
"kind": "blackbox",
"priority": "P1",
"description": "Invalid MCP transport is rejected without changing stored configuration",
"bindings": [{"profile": "api", "case": "fixed_api_mcp", "checks": ["api_chain", "mcp_invalid_transport_preserved"]}, {"profile": "api", "case": "shuffled_api_mcp", "checks": ["api_chain", "mcp_invalid_transport_preserved"]}]
}
]
}
+4
View File
@@ -14,9 +14,13 @@ GROUPS = {
'resources': 'regression_resource_cases',
'lifecycle': 'regression_lifecycle_cases',
'plugins': 'regression_plugin_cases',
'api': 'regression_api_cases',
}
DEFAULT_GROUPS = ('faults', 'resources', 'lifecycle', 'plugins')
class ScenarioUnavailable(RuntimeError):
"""An explicit prerequisite is missing; this is not a passed assertion."""
+243
View File
@@ -0,0 +1,243 @@
"""Challenge API job oracles and replay/cleanup boundaries without a running Core."""
from collections import Counter
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from regression_api_cases import run_case
from regression_api_checks import owned_resource, request
from regression_api_resources import _expect_rejection
from regression_ci import suite_profile
from regression_local_cases import DEFAULT_GROUPS
from regression_scenarios import expanded_profile
from regression_suite import Suite, validate
class ApiContractTests(unittest.TestCase):
def test_replay_and_job_isolation(self):
first, _ = suite_profile('api', 'run-a', api_seed=20261028)
replay, _ = suite_profile('api', 'run-b', api_seed=20261028)
other, _ = suite_profile('api', 'run-c', api_seed=2)
validate(first)
self.assertEqual(first['cases'], replay['cases'])
self.assertNotEqual(first['cases'], other['cases'])
half = len(first['cases']) // 2
fixed, shuffled = first['cases'][:half], first['cases'][half:]
self.assertEqual(Counter(c['scenario'] for c in fixed), Counter(c['scenario'] for c in shuffled))
self.assertEqual(set(Counter(c['scenario'] for c in fixed).values()), {1})
self.assertEqual(first['resources'], [])
self.assertNotIn('api', DEFAULT_GROUPS)
daily = expanded_profile(DEFAULT_GROUPS)
self.assertFalse(any(c.get('scenario', '').startswith('api_') for c in daily['cases']))
with self.assertRaisesRegex(ValueError, 'dedicated suite'):
expanded_profile(['api'])
def test_http_success_cannot_hide_business_failure_or_wrong_status(self):
for actual in ((200, {'code': -1}), (201, {'code': 0}), (400, {'code': 0}), (400, {})):
row = {}
with self.subTest(actual=actual), patch('regression_api_checks.api', return_value=actual):
with self.assertRaises(ValueError):
request(None, row, 'GET', '/api/test', expected=400 if actual[0] == 400 else 200)
self.assertEqual(row['assertions']['http_1'], 'failed')
self.assertEqual(row['http_steps'][0]['status'], actual[0])
def test_late_failure_cannot_mark_the_api_chain_passed(self):
from types import SimpleNamespace
suite = SimpleNamespace(profile={'vars': {'api_seed': 1}})
row = {}
with patch('regression_api_cases.credentials_case', side_effect=ValueError('Revoked key still works')):
with self.assertRaisesRegex(ValueError, 'Revoked key'):
run_case(suite, {'scenario': 'api_credentials', 'expect': 'OK'}, row)
self.assertEqual(row['assertions']['api_chain'], 'failed')
def test_rejection_keeps_http_failure_when_state_check_also_fails(self):
row = {}
def verify():
row['state_checked'] = True
raise ValueError('Stored state changed')
with patch('regression_api_resources.request', side_effect=ValueError('Expected 400, got 200')):
with self.assertRaisesRegex(ValueError, 'Expected 400, got 200'):
_expect_rejection(None, row, 'PUT', '/api/test', {}, expected=400, headers={}, verify=verify)
self.assertTrue(row['state_checked'])
def test_lost_create_response_cleans_only_exact_owned_resource(self):
for changed in (False, True):
with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory:
suite = Suite(
{'version': 1, 'cli': {'config': 'unused'}, 'cases': [], 'vars': {'workspace_uuid': 'test'}},
Path(directory) / 'profile.json',
Path(directory),
)
row, calls = {}, []
def api(suite, method, path, body=None, headers=None):
calls.append((method, path))
if method == 'POST':
raise TimeoutError('Lost response after create')
if path == '/collection':
return 200, {
'code': 0,
'data': {
'items': [{'name': 'qa-owned', 'uuid': 'owned'}, {'name': 'unrelated', 'uuid': 'other'}]
},
}
if calls[-2][0] == 'DELETE':
return 404, {'code': -1}
if method == 'GET':
return 200, {
'code': 0,
'data': {'item': {'name': 'unrelated' if changed else 'qa-owned', 'uuid': 'owned'}},
}
return 200, {'code': 0}
with patch('regression_api_checks.api', side_effect=api):
with self.assertRaisesRegex(TimeoutError, 'Lost response'):
with owned_resource(
suite, row, '/collection', {'name': 'qa-owned'}, item_key='item', list_key='items'
):
self.fail('Create timeout must not enter the contract body')
deleted = [path for method, path in calls if method == 'DELETE']
self.assertEqual(deleted, [] if changed else ['/collection/owned'])
if changed:
self.assertTrue(row['cleanup_errors'])
else:
self.assertEqual(row['owned_resources'][0]['state'], 'deleted')
def test_invalid_key_side_effects_fail_and_revoke_only_the_owned_key(self):
for status, invalid_field in ((200, 'scopes'), (400, 'scopes'), (200, 'expires_at'), (400, 'expires_at')):
with self.subTest(status=status, field=invalid_field), tempfile.TemporaryDirectory() as directory:
suite = Suite(
{
'version': 1,
'cli': {'config': 'unused'},
'cases': [],
'vars': {'workspace_uuid': 'test', 'api_seed': 1},
},
Path(directory) / 'profile.json',
Path(directory),
env={'LANGBOT_QA_USER_TOKEN': 'manager-secret'},
)
other = {'id': 1, 'name': 'qa-api-other-key', 'status': 'active', 'secret_available': False}
keys, row, deleted = {1: dict(other)}, {}, []
def api(suite, method, path, body=None, headers=None):
if path == '/api/v1/pipelines':
return 401, {'code': -1}
if path == '/api/v1/apikeys':
if method == 'GET':
return 200, {'code': 0, 'data': {'keys': [dict(key) for key in keys.values()]}}
self.assertEqual(method, 'POST')
if invalid_field not in body:
return 400, {'code': -1}
keys[7] = {'id': 7, 'name': body['name'], 'status': 'active', 'secret_available': False}
return status, {'code': 0 if status == 200 else -1, 'data': {'key': dict(keys[7])}}
self.assertEqual(path, '/api/v1/apikeys/7')
if method == 'DELETE':
deleted.append(path)
keys[7]['status'] = 'revoked'
return 200, {'code': 0}
self.assertEqual(method, 'GET')
return 200, {'code': 0, 'data': {'key': dict(keys[7])}}
error = 'expected HTTP 400' if status == 200 else 'Invalid key request changed key list'
with (
patch('regression_api_checks.api', side_effect=api),
patch('regression_api_cases.unique_name', return_value='qa-api-owned-key'),
self.assertRaisesRegex(ValueError, error),
):
run_case(suite, {'scenario': 'api_credentials', 'expect': 'OK'}, row)
self.assertEqual(row['assertions']['api_chain'], 'failed')
self.assertEqual(row['assertions']['key_cleanup'], 'passed')
self.assertFalse(row.get('cleanup_errors'))
self.assertEqual(deleted, ['/api/v1/apikeys/7'])
self.assertEqual(keys[7]['status'], 'revoked')
self.assertEqual(keys[1], other)
self.assertEqual(set(keys), {1, 7})
self.assertEqual(row['owned_resources'][0]['state'], 'revoked')
def test_readonly_pipeline_side_effects_fail_and_cleanup_preserves_other_resources(self):
for status in (200, 403):
with self.subTest(status=status), tempfile.TemporaryDirectory() as directory:
suite = Suite(
{
'version': 1,
'cli': {'config': 'unused'},
'cases': [],
'vars': {'workspace_uuid': 'test', 'api_seed': 1},
},
Path(directory) / 'profile.json',
Path(directory),
env={'LANGBOT_QA_USER_TOKEN': 'manager-secret'},
)
other_key = {'id': 1, 'name': 'qa-api-other-key', 'status': 'active', 'secret_available': False}
other_pipeline = {'uuid': 'other-pipeline', 'name': 'qa-api-other-pipeline'}
keys, pipelines = {1: dict(other_key)}, {'other-pipeline': dict(other_pipeline)}
row, deleted = {}, []
def api(suite, method, path, body=None, headers=None):
if path == '/api/v1/apikeys':
if method == 'GET':
return 200, {'code': 0, 'data': {'keys': [dict(key) for key in keys.values()]}}
self.assertEqual(method, 'POST')
if body.get('scopes') == ['qa.invalid'] or 'expires_at' in body:
return 400, {'code': -1}
keys[7] = {'id': 7, 'name': body['name'], 'status': 'active', 'secret_available': False}
return 200, {'code': 0, 'data': {'key': {**keys[7], 'key': 'child-secret'}}}
if path == '/api/v1/apikeys/7':
if method == 'PUT':
keys[7]['name'] = body['name']
return 200, {'code': 0}
if method == 'DELETE':
deleted.append(path)
keys[7]['status'] = 'revoked'
return 200, {'code': 0}
self.assertEqual(method, 'GET')
return 200, {'code': 0, 'data': {'key': dict(keys[7])}}
if path == '/api/v1/pipelines':
if headers in ({}, {'Authorization': 'Bearer qa-invalid'}):
return 401, {'code': -1}
if method == 'GET':
return 200, {'code': 0, 'data': {'pipelines': [dict(item) for item in pipelines.values()]}}
self.assertEqual(method, 'POST')
self.assertEqual(headers, {'X-API-Key': 'child-secret'})
pipelines['owned-pipeline'] = {'uuid': 'owned-pipeline', 'name': body['name']}
return status, {'code': 0 if status == 200 else -1, 'data': {'uuid': 'owned-pipeline'}}
self.assertEqual(path, '/api/v1/pipelines/owned-pipeline')
if method == 'DELETE':
deleted.append(path)
del pipelines['owned-pipeline']
return 200, {'code': 0}
self.assertEqual(method, 'GET')
if 'owned-pipeline' not in pipelines:
return 404, {'code': -1}
return 200, {'code': 0, 'data': {'pipeline': dict(pipelines['owned-pipeline'])}}
error = 'expected HTTP 403' if status == 200 else 'Read-only key changed resources'
with (
patch('regression_api_checks.api', side_effect=api),
patch('regression_api_cases.unique_name', return_value='qa-api-owned-key'),
self.assertRaisesRegex(ValueError, error),
):
run_case(suite, {'scenario': 'api_credentials', 'expect': 'OK'}, row)
self.assertEqual(row['assertions']['api_chain'], 'failed')
self.assertEqual(row['assertions']['key_cleanup'], 'passed')
self.assertEqual(row['assertions']['cleanup_pipeline_qa-api-owned-key'], 'passed')
self.assertFalse(row.get('cleanup_errors'))
self.assertEqual(deleted, ['/api/v1/pipelines/owned-pipeline', '/api/v1/apikeys/7'])
self.assertEqual(keys[7]['status'], 'revoked')
self.assertEqual(keys[1], other_key)
self.assertEqual(set(keys), {1, 7})
self.assertEqual(pipelines, {'other-pipeline': other_pipeline})
self.assertEqual(
{item['kind']: item['state'] for item in row['owned_resources']},
{'api-key': 'revoked', 'pipeline': 'deleted'},
)
if __name__ == '__main__':
unittest.main()
+47
View File
@@ -143,6 +143,53 @@ class DailySummaryTest(unittest.TestCase):
else:
self.assertNotIn('Daily regression: PASS', text)
def test_api_summary_keeps_preparation_and_outer_failures_visible(self):
workflow = Path(__file__).resolve().parents[1] / '.github/workflows/daily-endurance.yml'
steps = yaml.safe_load(workflow.read_text())['jobs']['api-contracts']['steps']
command = next(step['run'] for step in steps if step.get('name') == 'Publish API result')
suite = {'passed': True, 'requests': 14, 'status': {'passed': 14}, 'elapsed_seconds': 7.3}
for gate, actual_suite, expected in [
({'passed': True, 'api_seed': 123}, suite, 'Daily API contracts: PASS'),
(
{'passed': False, 'infrastructure_error': 'plugin installation failed'},
None,
'plugin installation failed',
),
({'passed': False, 'timed_out': True}, suite, '600-second outer process guard'),
({'passed': False, 'resource_errors': ['sampling failed']}, suite, 'sampling failed'),
({'passed': False, 'coverage_errors': ['missing evidence']}, suite, 'missing evidence'),
({'passed': False}, {'passed': False, 'errors': ['MCP mode persisted']}, 'MCP mode persisted'),
(None, None, 'Missing verdict'),
]:
with self.subTest(gate=gate), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / '.qa/api/gate/suite'
target.mkdir(parents=True)
if gate is not None:
(target.parent / 'gate.json').write_text(json.dumps(gate))
if actual_suite is not None:
(target / 'summary.json').write_text(json.dumps(actual_suite))
summary = root / 'summary.md'
subprocess.run(
['bash', '-e', '-c', command],
cwd=root,
check=True,
capture_output=True,
env={
**os.environ,
'PATH': str(Path(sys.executable).parent) + os.pathsep + os.environ['PATH'],
'GITHUB_STEP_SUMMARY': str(summary),
'GITHUB_SHA': 'tested-core',
'GITHUB_RUN_ID': '456',
},
)
text = summary.read_text()
self.assertIn(expected, text)
self.assertIn('not reported', text)
self.assertIn('shuffle seed: `123`' if gate and gate.get('passed') else 'shuffle seed: `456`', text)
if not gate or not gate.get('passed'):
self.assertNotIn('Daily API contracts: PASS', text)
def test_core_summary_records_preparation_failure_and_selected_plugins(self):
workflow = Path(__file__).resolve().parents[1] / '.github/workflows/core-smoke.yml'
steps = yaml.safe_load(workflow.read_text())['jobs']['smoke']['steps']
+9 -5
View File
@@ -289,12 +289,16 @@ class Routes:
name = binding['profile']
if name not in profiles:
path = Path(__file__).with_name('regression_profiles') / f'{name}.json'
if name == 'scenarios':
from regression_scenarios import expanded_profile
from regression_local_cases import GROUPS
if name == 'api':
from regression_ci import suite_profile
profile = expanded_profile(GROUPS)
if 'lifecycle' in GROUPS:
profile, _ = suite_profile('api', 'catalog-test')
elif name == 'scenarios':
from regression_scenarios import expanded_profile
from regression_local_cases import DEFAULT_GROUPS
profile = expanded_profile(DEFAULT_GROUPS)
if 'lifecycle' in DEFAULT_GROUPS:
from regression_lifecycle_cases import restart_profile
profile['cases'] += restart_profile(profile)['cases']
+2 -2
View File
@@ -7,7 +7,7 @@ import tempfile
import unittest
from unittest.mock import patch
from regression_local_cases import GROUPS, ScenarioUnavailable
from regression_local_cases import DEFAULT_GROUPS, ScenarioUnavailable
from regression_scenarios import expanded_profile
from regression_smoke import run_smoke
from regression_suite import Suite, validate
@@ -17,7 +17,7 @@ from regression_traffic import reconcile_runs
class LocalScenarioTests(unittest.TestCase):
def test_group_switches_keep_base_cases_and_reject_unknown_handlers(self):
base = expanded_profile()
for group in GROUPS:
for group in DEFAULT_GROUPS:
profile = expanded_profile([group])
validate(profile)
self.assertEqual(profile['scenario_groups'], [group])