diff --git a/tools/regression_contracts.json b/tools/regression_contracts.json index 048355c99..8d0729e30 100644 --- a/tools/regression_contracts.json +++ b/tools/regression_contracts.json @@ -173,7 +173,7 @@ {"id": "auth.revocation", "area": "auth", "kind": "blackbox", "priority": "P0", "description": "Revoked credentials stop authorizing new requests", - "bindings": [] + "bindings": [{"profile": "scenarios", "case": "auth_revocation", "checks": ["credential_active", "credential_revoked", "key_status", "key_cleanup"]}] }, {"id": "workspace.read_isolation", "area": "workspace", "kind": "blackbox", "priority": "P0", @@ -248,7 +248,7 @@ {"id": "agent.direct_execution", "area": "agent", "kind": "blackbox", "priority": "P1", "description": "Standalone Agent entrypoint completes through the public API", - "bindings": [] + "bindings": [{"profile": "scenarios", "case": "agent_direct_execution", "checks": ["agent_response", "agent_terminal", "agent_events", "agent_cleanup"]}] }, {"id": "persistence.postgres_parity", "area": "persistence", "kind": "blackbox", "priority": "P1", @@ -265,6 +265,7 @@ "description": "A real embedding model retrieves relevant fixed documents", "bindings": [] }, + {"id": "retrieval.delete_visibility", "area": "retrieval", "kind": "blackbox", "priority": "P1", "description": "Deleting one ingested document removes its retrieval results while preserving a second document", "bindings": [{"profile": "scenarios", "case": "retrieval_delete_visibility", "checks": ["retrieval_initial", "retrieval_deleted", "retrieval_control", "document_cleanup"]}]}, {"id": "execution.persisted_terminal", "area": "execution", "kind": "graybox", "priority": "P0", "description": "Each executed request has a unique completed persisted run and terminal event", diff --git a/tools/regression_local_cases.py b/tools/regression_local_cases.py new file mode 100644 index 000000000..951360e50 --- /dev/null +++ b/tools/regression_local_cases.py @@ -0,0 +1,105 @@ +"""Optional local scenarios share the Suite's verified Workspace and credentials.""" + +from contextlib import closing +import importlib +import json +from pathlib import Path +import sqlite3 +import time +import urllib.error +import urllib.request + +GROUPS = {'resources': 'regression_resource_cases'} + + +class ScenarioUnavailable(RuntimeError): + """An explicit prerequisite is missing; this is not a passed assertion.""" + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def endpoint(suite, path): + require(path.startswith('/') and not path.startswith('//'), 'Scenario API path must be relative to Core') + base = suite.connection.get('endpoint') + require(bool(base), 'Scenario needs the endpoint verified by lbctl whoami') + return base.rstrip('/') + path + + +def auth_headers(suite): + source = suite.connection.get('credential_source', '') + key = suite.env.get(source.removeprefix('env:')) if source.startswith('env:') else None + require(bool(key), 'Scenario requires environment-backed lbctl credentials') + if key not in suite.secrets: + suite.secrets.append(key) + return {'Authorization': 'Bearer ' + key, 'X-Workspace-Id': suite.variables['workspace_uuid']} + + +headers = auth_headers + + +def api(suite, method, path, body=None, headers=None): + request_headers = dict(auth_headers(suite) if headers is None else headers) + request_headers.setdefault('X-Workspace-Id', suite.variables['workspace_uuid']) + if body is not None: + request_headers['Content-Type'] = 'application/json' + request = urllib.request.Request( + endpoint(suite, path), + method=method, + headers=request_headers, + data=json.dumps(body).encode() if body is not None else None, + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + response = opener.open(request, timeout=suite.timeout(90)) + except urllib.error.HTTPError as exc: + response = exc + with response: + return response.status, json.load(response) + + +def record_run(suite, row, run_id, expected_status): + """Register exact run ownership for the independent final ledger check.""" + require(expected_status in {'completed', 'failed', 'cancelled', 'timeout'}, 'Expected Run status must be terminal') + path = Path(suite.path(suite.profile['smoke']['ledger_db'])).resolve() + deadline = time.monotonic() + suite.timeout(30) + with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=5)) as connection: + connection.row_factory = sqlite3.Row + while True: + run = connection.execute( + 'SELECT run_id, conversation_id, status, finished_at FROM agent_run ' + 'WHERE workspace_id=? AND run_id=? AND id>?', + (suite.variables['workspace_uuid'], run_id, row['after_id']), + ).fetchone() + if run and run['finished_at']: + observed = dict(run) + observed.update(expected_status=expected_status, after_id=row['after_id']) + row.setdefault('scenario_runs', []).append(observed) + require(run['status'] == expected_status, f'Run ended as {run["status"]}; expected {expected_status}') + return observed + if time.monotonic() >= deadline: + raise TimeoutError('Scenario Run did not reach a persisted terminal state') + time.sleep(0.1) + + +def modules(): + return {name: importlib.import_module(module) for name, module in GROUPS.items()} + + +def add_cases(profile, groups): + groups = list(groups) + require(len(groups) == len(set(groups)), 'Scenario groups must not repeat') + require(not (set(groups) - GROUPS.keys()), 'Unknown local scenario group') + for name in groups: + importlib.import_module(GROUPS[name]).add_cases(profile) + profile['scenario_groups'] = groups + + +def run_case(suite, case, row): + for name, module in modules().items(): + if case['scenario'] in module.CASE_NAMES: + require(name in suite.profile.get('scenario_groups', []), f'Scenario group is disabled: {name}') + return module.run_case(suite, case, row) + raise ValueError(f'Unknown local scenario: {case["scenario"]}') diff --git a/tools/regression_resource_cases.py b/tools/regression_resource_cases.py new file mode 100644 index 000000000..c1db30c48 --- /dev/null +++ b/tools/regression_resource_cases.py @@ -0,0 +1,342 @@ +"""Optional resource lifecycle scenarios against the authenticated Workspace APIs.""" + +import copy +import json +from pathlib import Path +import sys +import tempfile +from uuid import uuid4 + +from regression_coverage import checked +from regression_local_cases import ScenarioUnavailable, api, require +from regression_suite import resolve + +GROUP = 'resources' +CASE_NAMES = {'auth_revocation', 'agent_direct_execution', 'retrieval_delete_visibility'} + + +def add_cases(profile): + resources = {item['id']: item for item in profile['resources']} + base = copy.deepcopy(resources['empty_knowledge_base']) + base.update(id='resource_knowledge_base') + base['body']['name'] = 'regression document lifecycle' + base.pop('documents', None) + profile['resources'].append(base) + for name, stage, expected in ( + ('auth_revocation', 'auth', 'LANGBOT_AUTH_REVOCATION_OK'), + ('agent_direct_execution', 'agent', 'LANGBOT_CHAT_OK'), + ('retrieval_delete_visibility', 'retrieval', 'LANGBOT_DOCUMENT_DELETE_OK'), + ): + profile['cases'].append({'name': name, 'scenario': name, 'stage': stage, 'expect': expected}) + + +def data(suite, method, path, body=None, *, headers=None): + status, response = api(suite, method, path, body, headers=headers) + require( + status == 200 and isinstance(response, dict) and response.get('code') == 0, + f'{method} {path} did not return a successful response (HTTP {status})', + ) + return response.get('data', {}) + + +def journal(suite, row): + row.setdefault('resource_journal', f'resource-case-{uuid4().hex}.json') + suite.write( + row['resource_journal'], + { + 'workspace_uuid': suite.variables['workspace_uuid'], + 'resources': row.get('owned_resources', []), + 'cleanup_errors': row.get('cleanup_errors', []), + }, + ) + + +def cleanup(suite, row, assertion, action): + original = sys.exc_info()[1] + try: + checked(row, assertion, action) + except Exception as exc: + message = f'{type(exc).__name__}: {exc}' + for secret in suite.secrets: + message = message.replace(secret, '[REDACTED]') + row.setdefault('cleanup_errors', []).append(message) + if original is None: + raise + finally: + journal(suite, row) + + +def auth_revocation(suite, case, row): + token = suite.env.get('LANGBOT_QA_USER_TOKEN') + if not token: + raise ScenarioUnavailable('auth.revocation requires LANGBOT_QA_USER_TOKEN with api_key.manage') + if token not in suite.secrets: + suite.secrets.append(token) + manager = {'Authorization': 'Bearer ' + token} + name = 'qa-revoke-' + uuid4().hex + owned = {'kind': 'api-key', 'name': name, 'state': 'creating'} + row.setdefault('owned_resources', []).append(owned) + journal(suite, row) + + def revoke_owned(): + if 'id' not in owned: + keys = data(suite, 'GET', '/api/v1/apikeys', headers=manager)['keys'] + matches = [key for key in keys if key.get('name') == name] + require(len(matches) == 1, 'Created key identity is unresolved; inspect the resource journal') + owned['id'] = matches[0]['id'] + journal(suite, row) + path = f'/api/v1/apikeys/{owned["id"]}' + key = data(suite, 'GET', path, headers=manager)['key'] + if key['status'] != 'revoked': + data(suite, 'DELETE', path, headers=manager) + key = data(suite, 'GET', path, headers=manager)['key'] + require(key['status'] == 'revoked', 'Owned credential remains active after cleanup') + owned['state'] = 'revoked' + + try: + created = data(suite, 'POST', '/api/v1/apikeys', {'name': name, 'scopes': ['resource.view']}, headers=manager)[ + 'key' + ] + owned.update(id=created['id'], uuid=created['uuid'], state='active') + journal(suite, row) + secret = created['key'] + require(isinstance(secret, str) and bool(secret), 'Key creation did not return its one-time secret') + suite.secrets.append(secret) + child = {'X-API-Key': secret} + identity = data(suite, 'GET', '/api/v1/system/context', headers=child) + checked( + row, + 'credential_active', + lambda: require( + identity.get('workspace_uuid') == suite.variables['workspace_uuid'] + and identity.get('api_key_id') == owned['uuid'], + 'New credential did not authenticate as its own Workspace key', + ), + ) + data(suite, 'GET', '/api/v1/pipelines', headers=child) + data(suite, 'DELETE', f'/api/v1/apikeys/{owned["id"]}', headers=manager) + status, response = api(suite, 'GET', '/api/v1/pipelines', headers=child) + checked( + row, + 'credential_revoked', + lambda: require( + status == 401 and isinstance(response, dict) and response.get('code') != 0, + 'Revoked credential still authorizes resource access', + ), + ) + key = data(suite, 'GET', f'/api/v1/apikeys/{owned["id"]}', headers=manager)['key'] + checked( + row, + 'key_status', + lambda: require( + key.get('status') == 'revoked' and key.get('secret_available') is False and 'key' not in key, + 'Revoked key readback has incorrect status or exposes its secret', + ), + ) + return {'result': case['expect'], 'key_id': owned['id'], 'status': 'revoked'} + finally: + cleanup(suite, row, 'key_cleanup', revoke_owned) + + +def agent_direct_execution(suite, case, row): + template = next(item for item in suite.profile['resources'] if item['id'] == 'chat_pipeline') + config = resolve(copy.deepcopy(template['body']['config']['ai']), suite.variables) + name = 'qa-agent-' + uuid4().hex + conversation = 'qa-direct-' + uuid4().hex + owned = {'kind': 'agent', 'name': name, 'state': 'creating'} + row.setdefault('owned_resources', []).append(owned) + journal(suite, row) + + def delete_owned(): + if 'uuid' not in owned: + agents = data(suite, 'GET', '/api/v1/agents')['agents'] + matches = [agent for agent in agents if agent.get('name') == name and agent.get('kind') == 'agent'] + require(len(matches) == 1, 'Created Agent identity is unresolved; inspect the resource journal') + owned['uuid'] = matches[0]['uuid'] + journal(suite, row) + path = f'/api/v1/agents/{owned["uuid"]}' + status, _ = api(suite, 'GET', path) + if status != 404: + require(status == 200, 'Cannot read owned Agent before cleanup') + data(suite, 'DELETE', path) + status, _ = api(suite, 'GET', path) + require(status == 404, 'Owned Agent remains visible after cleanup') + owned['state'] = 'deleted' + + try: + created = data(suite, 'POST', '/api/v1/agents', {'name': name, 'kind': 'agent', 'config': config}) + owned.update(uuid=created['uuid'], state='created') + journal(suite, row) + path = f'/api/v1/agents/{owned["uuid"]}' + reply = data( + suite, 'POST', path + '/debug', {'text': 'Run the direct Agent scenario', 'conversation_id': conversation} + ) + runs = data(suite, 'GET', path + '/runs')['items'] + matches = [run for run in runs if run.get('conversation_id') == conversation] + require(len(matches) == 1, 'Direct Agent request did not produce exactly one matching Run') + run = matches[0] + row.setdefault('scenario_runs', []).append( + { + 'run_id': run['run_id'], + 'conversation_id': conversation, + 'expected_status': 'completed', + 'after_id': row['after_id'], + } + ) + checked( + row, + 'agent_terminal', + lambda: require( + run.get('status') == 'completed' and bool(run.get('finished_at')), + 'Direct Agent Run did not reach completed status', + ), + ) + checked( + row, + 'agent_response', + lambda: require( + reply.get('conversation_id') == conversation and case['expect'] in reply.get('final_text', ''), + 'Direct Agent returned the wrong conversation or business reply', + ), + ) + events = data(suite, 'GET', path + f'/runs/{run["run_id"]}/events') + checked( + row, + 'agent_events', + lambda: require( + events.get('run', {}).get('run_id') == run['run_id'] + and any( + event.get('type') == 'run.completed' and event.get('run_id') == run['run_id'] + for event in events.get('items', []) + ), + 'Direct Agent completion event is missing', + ), + ) + return {'result': reply['final_text'], 'run_id': run['run_id'], 'conversation_id': conversation} + finally: + cleanup(suite, row, 'agent_cleanup', delete_owned) + + +def retrieval_delete_visibility(suite, case, row): + kb_uuid = suite.variables['resource_knowledge_base'] + path = f'/api/v1/knowledge/bases/{kb_uuid}' + before = {item['uuid'] for item in data(suite, 'GET', path + '/files')['files']} + owned = {'kind': 'knowledge-files', 'knowledge_base_uuid': kb_uuid, 'files': [], 'state': 'ingesting'} + row.setdefault('owned_resources', []).append(owned) + journal(suite, row) + + def files(): + return [item for item in data(suite, 'GET', path + '/files')['files'] if item['uuid'] not in before] + + def cleanup_documents(): + remaining = files() + owned['files'] = [{'uuid': item['uuid'], 'status': item['status']} for item in remaining] + journal(suite, row) + unresolved = [item['uuid'] for item in remaining if item['status'] in {'pending', 'processing', 'interrupted'}] + for item in remaining: + if item['uuid'] not in unresolved: + data(suite, 'DELETE', path + '/files/' + item['uuid']) + require( + not unresolved, 'Document ingestion outcome is unresolved; retain the owned knowledge base for inspection' + ) + require(not files(), 'Owned documents remain visible after cleanup') + owned['state'] = 'deleted' + + def retrieve(query): + found = data(suite, 'POST', path + '/retrieve', {'query': query, 'retrieval_settings': {'top_k': 10}})[ + 'results' + ] + require( + isinstance(found, list) + and all( + isinstance(item, dict) + and isinstance(item.get('metadata'), dict) + and isinstance(item.get('content'), list) + for item in found + ), + 'Malformed retrieval response', + ) + return found + + def contains(found, document, marker): + return any( + item.get('metadata', {}).get('file_id') == document['engine_document_id'] + and marker in json.dumps(item.get('content', []), ensure_ascii=False) + for item in found + ) + + try: + documents, markers = [], ['obsolete' + uuid4().hex, 'retained' + uuid4().hex] + with tempfile.TemporaryDirectory(prefix='resource-documents-', dir=suite.output) as directory: + for index, marker in enumerate(markers): + document = Path(directory) / f'document-{index}.txt' + document.write_text(f'Lifecycle fixture: {marker}. This is document {index}.\n') + previous = {item['uuid'] for item in files()} + suite.call( + [ + 'knowledge-base', + 'ingest', + kb_uuid, + '--file', + str(document), + '--parser-plugin-id', + 'langbot-team/GeneralParsers', + '--wait', + ] + ) + added = [item for item in files() if item['uuid'] not in previous] + require( + len(added) == 1 and added[0].get('status') == 'completed', + 'Document ingestion did not create exactly one completed file', + ) + documents.append(added[0]) + owned['files'].append({'uuid': added[0]['uuid'], 'status': added[0]['status']}) + journal(suite, row) + initial = retrieve(' '.join(markers)) + checked( + row, + 'retrieval_initial', + lambda: require( + all(contains(initial, document, marker) for document, marker in zip(documents, markers)), + 'Both fixture documents must be independently retrievable before deletion', + ), + ) + data(suite, 'DELETE', path + '/files/' + documents[0]['uuid']) + after = retrieve(' '.join(markers)) + checked( + row, + 'retrieval_deleted', + lambda: require( + all( + item.get('metadata', {}).get('file_id') != documents[0]['engine_document_id'] + and markers[0] not in json.dumps(item, ensure_ascii=False) + for item in after + ) + and documents[0]['uuid'] not in {item['uuid'] for item in files()}, + 'Deleted document remains visible in retrieval or the file list', + ), + ) + checked( + row, + 'retrieval_control', + lambda: require( + contains(after, documents[1], markers[1]), + 'Deleting one document removed the surviving control document', + ), + ) + return { + 'result': case['expect'], + 'deleted_document_id': documents[0]['uuid'], + 'control_document_id': documents[1]['uuid'], + } + finally: + cleanup(suite, row, 'document_cleanup', cleanup_documents) + + +def run_case(suite, case, row): + actions = { + 'auth_revocation': auth_revocation, + 'agent_direct_execution': agent_direct_execution, + 'retrieval_delete_visibility': retrieval_delete_visibility, + } + return actions[case['scenario']](suite, case, row) diff --git a/tools/regression_scenarios.py b/tools/regression_scenarios.py index f95fa7598..a7e37deef 100644 --- a/tools/regression_scenarios.py +++ b/tools/regression_scenarios.py @@ -11,7 +11,7 @@ ROOT = Path(__file__).parent RUNNER = 'plugin:langbot-team/LocalAgent/default' -def expanded_profile(): +def expanded_profile(groups=()): profile = json.loads((ROOT / 'regression_profiles/smoke.json').read_text()) profile['coverage_suite'] = 'scenarios' profile['smoke']['group_turns'] = 3 @@ -102,6 +102,10 @@ def expanded_profile(): expect='LANGBOT_' + name.upper() + '_OK', ) profile['cases'].append(case) + if groups: + from regression_local_cases import add_cases + + add_cases(profile, groups) return profile diff --git a/tools/regression_smoke.py b/tools/regression_smoke.py index c996c0ff5..2d7a32e19 100644 --- a/tools/regression_smoke.py +++ b/tools/regression_smoke.py @@ -16,6 +16,7 @@ from uuid import uuid4 from regression_traffic import group_request, percentile, read_endpoint_snapshot, reconcile_runs from regression_coverage import checked, check_response +from regression_local_cases import ScenarioUnavailable class UnresolvedRun(RuntimeError): @@ -316,7 +317,11 @@ def run_cycle(suite, rows, health, cycle): token = uuid4().hex offset = mock_path.stat().st_size if case.get('mock_requests') and mock_path.exists() else 0 try: - if rejected: + if 'scenario' in case: + from regression_local_cases import run_case + + data = run_case(suite, case, row) + elif rejected: data = checked( row, 'response', @@ -358,7 +363,7 @@ def run_cycle(suite, rows, health, cycle): row['output'] = data if case.get('forbid'): checked(row, 'isolation', lambda: check_response(data, case['expect'], case['forbid'])) - row['checks'] = {} + row.setdefault('checks', {}) if case.get('fields'): row['checks']['fields'] = checked(row, 'fields', lambda: check_fields(data, case['fields'])) if case.get('attachment'): @@ -392,6 +397,8 @@ def run_cycle(suite, rows, health, cycle): row['status'] = 'passed' previous[group] = token history_turns[group] += 1 + except ScenarioUnavailable as exc: + row.update(status='skipped', error=redact(str(exc))) except urllib.error.HTTPError as exc: row.update( status='unknown' if exc.code >= 500 else 'failed', error=f'HTTP {exc.code}; not retried' @@ -413,6 +420,8 @@ def run_cycle(suite, rows, health, cycle): if 'timed out' in message: row['timed_out'] = True row['seconds'] = time.monotonic() - before + for error in row.get('cleanup_errors', []): + errors.append(f'{case["name"]}: cleanup failed: {redact(str(error))}') if row['status'] != 'passed': errors.append(f'{case["name"]}: {row["status"]}: {row["error"]}') if is_group: @@ -462,7 +471,7 @@ def run_smoke(suite): } ) suite.write('cycles.json', cycles) - if not execution or not execution.admitting() or any(row['status'] == 'unknown' for row in rows): + if not execution or not execution.admitting() or any(row['status'] in ('unknown', 'skipped') for row in rows): break result['errors'] = errors result['cycles'] = cycles @@ -470,7 +479,7 @@ def run_smoke(suite): result['incomplete'] = [ f'{row["case"]}: {row["status"]}: {row["error"]}' for row in rows - if row['status'] == 'unknown' and not row.get('timed_out') + if row['status'] in ('unknown', 'skipped') and not row.get('timed_out') ] if not result['first_pass_complete']: error = 'Required first pass did not finish before the admission deadline' @@ -486,7 +495,7 @@ def run_smoke(suite): for name in dict.fromkeys(row['case'] for row in rows) }, } - executed = [row for row in rows if row.get('requires_runner') and 'after_id' in row] + executed = scenario_run_rows(rows) result['test_data'] = { 'observed_conversations': len({row['conversation_id'] for row in executed if 'conversation_id' in row}), 'observed_agent_runs': len({row['run']['run_id'] for row in executed if 'run' in row}), @@ -497,9 +506,24 @@ def run_smoke(suite): return result +def scenario_run_rows(rows): + executed = [row for row in rows if row.get('requires_runner') and 'after_id' in row] + for row in rows: + for run in row.get('scenario_runs', []): + executed.append( + { + 'after_id': row['after_id'], + 'conversation_id': run['conversation_id'], + 'run': {'run_id': run['run_id']}, + 'expected_status': run['expected_status'], + } + ) + return executed + + def reconcile_scenario_runs(suite, result, *, key='ledger'): """Recheck the originally observed conversations without expanding run ownership.""" - executed = [row for row in result.get('cases', []) if row.get('requires_runner') and 'after_id' in row] + executed = scenario_run_rows(result.get('cases', [])) if not executed: return try: @@ -509,6 +533,9 @@ def reconcile_scenario_runs(suite, result, *, key='ledger'): min(row['after_id'] for row in executed), {row['conversation_id'] for row in executed if 'conversation_id' in row}, {row['run']['run_id'] for row in executed if 'run' in row}, + expected_statuses={ + row['run']['run_id']: row.get('expected_status', 'completed') for row in executed if 'run' in row + }, ) except (OSError, sqlite3.Error) as exc: error = f'Scenario {key} evidence unavailable: {exc}' @@ -516,5 +543,5 @@ def reconcile_scenario_runs(suite, result, *, key='ledger'): result.setdefault('incomplete', []).append(error) return if not result[key]['matched']: - error = f'Scenario {key} found extra, missing or non-completed AgentRuns' + error = f'Scenario {key} found extra, missing or unexpected AgentRun states' result['errors'].append(error) diff --git a/tools/regression_suite.py b/tools/regression_suite.py index 73c152689..ba5bd0073 100644 --- a/tools/regression_suite.py +++ b/tools/regression_suite.py @@ -83,6 +83,7 @@ def validate(profile): 'coverage_suite', 'execution', 'performance_mode', + 'scenario_groups', } if extra := profile.keys() - allowed: raise ValueError(f'unknown profile fields: {sorted(extra)}') @@ -98,6 +99,16 @@ def validate(profile): raise ValueError('coverage_suite must be a stable suite identifier') if profile.get('performance_mode', 'gate') not in {'gate', 'observe'}: raise ValueError('performance_mode must be gate or observe') + if 'scenario_groups' in profile: + from regression_local_cases import GROUPS + + groups = profile['scenario_groups'] + if ( + not isinstance(groups, list) + or any(not isinstance(name, str) or name not in GROUPS for name in groups) + or len(groups) != len(set(groups)) + ): + raise ValueError('scenario_groups must contain unique supported local groups') execution = profile.get('execution') if execution is not None: if ( @@ -192,6 +203,7 @@ def validate(profile): 'reject_group', 'http_probe', 'reset_group', + 'scenario', }: raise ValueError('unknown case field') if not isinstance(case.get('name'), str) or not case['name'] or case['name'] in names: @@ -250,7 +262,21 @@ def validate(profile): case['reset_group'] is not True or 'http_group' not in case or 'reject_group' in case ): raise ValueError('reset_group requires an accepted group case') - if 'http_probe' in case: + if 'scenario' in case: + from regression_local_cases import modules + + handlers = modules() + if ( + 'smoke' not in profile + or not isinstance(case['scenario'], str) + or not any( + case['scenario'] in module.CASE_NAMES and name in profile.get('scenario_groups', []) + for name, module in handlers.items() + ) + or any(key in case for key in ('args', 'body', 'http_probe', 'http_group', 'box_exec', 'attachment')) + ): + raise ValueError('scenario must name a supported finite local case without another request mode') + elif 'http_probe' in case: probe = case['http_probe'] if ( not isinstance(probe, dict) diff --git a/tools/regression_traffic.py b/tools/regression_traffic.py index aa1409951..05f5b5fbe 100644 --- a/tools/regression_traffic.py +++ b/tools/regression_traffic.py @@ -131,9 +131,14 @@ def group_request(case, group_id, member_id, turn, token, previous, env=None, *, return payload -def reconcile_runs(path, workspace, after_id, conversations, expected_ids): +def reconcile_runs(path, workspace, after_id, conversations, expected_ids, *, expected_statuses=None): """Catch delayed extra or nonterminal runs after request-level verification.""" actual, unfinished = set(), [] + expected_statuses = expected_statuses or {} + if set(expected_statuses) - set(expected_ids) or any( + status not in {'completed', 'failed', 'cancelled', 'timeout'} for status in expected_statuses.values() + ): + raise ValueError('Expected statuses must name owned runs and terminal states') connection = sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro', uri=True) try: values = sorted(conversations) @@ -145,7 +150,7 @@ def reconcile_runs(path, workspace, after_id, conversations, expected_ids): ) for run_id, status in connection.execute(query, [workspace, after_id, *part]): actual.add(run_id) - if status != 'completed': + if status != expected_statuses.get(run_id, 'completed'): unfinished.append(run_id) finally: connection.close() diff --git a/tools/test_regression_coverage.py b/tools/test_regression_coverage.py index 163f8c68b..f0d7f5870 100644 --- a/tools/test_regression_coverage.py +++ b/tools/test_regression_coverage.py @@ -291,8 +291,13 @@ class Routes: 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 - profile = expanded_profile() + profile = expanded_profile(GROUPS) + if 'lifecycle' in GROUPS: + from regression_lifecycle_cases import restart_profile + + profile['cases'] += restart_profile(profile)['cases'] else: profile = json.loads(path.read_text()) profiles[name] = {x['name']: x for x in profile['cases']} diff --git a/tools/test_regression_local_cases.py b/tools/test_regression_local_cases.py new file mode 100644 index 000000000..e1a42e040 --- /dev/null +++ b/tools/test_regression_local_cases.py @@ -0,0 +1,110 @@ +"""Keep optional cases and expected failures from turning incomplete evidence green.""" + +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +from regression_local_cases import GROUPS, ScenarioUnavailable +from regression_scenarios import expanded_profile +from regression_smoke import run_smoke +from regression_suite import Suite, validate +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: + profile = expanded_profile([group]) + validate(profile) + self.assertEqual(profile['scenario_groups'], [group]) + self.assertGreater(len(profile['cases']), len(base['cases'])) + self.assertEqual(profile['cases'][: len(base['cases'])], base['cases']) + for groups in (['unknown'], ['resources', 'resources']): + with self.assertRaises(ValueError): + expanded_profile(groups) + profile = expanded_profile(['resources']) + profile['scenario_groups'] = [] + with self.assertRaises(ValueError): + validate(profile) + profile['scenario_groups'] = ['resources'] + profile['cases'][-1]['scenario'] = 'not-a-real-handler' + with self.assertRaises(ValueError): + validate(profile) + + def test_missing_prerequisite_is_skipped_and_incomplete(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with sqlite3.connect(root / 'ledger.db') as connection: + connection.execute('CREATE TABLE agent_run (id INTEGER)') + profile = { + 'version': 1, + 'cli': {'config': 'context.yaml'}, + 'smoke': {'ledger_db': 'ledger.db'}, + 'cases': [{'name': 'auth_revocation', 'scenario': 'auth_revocation', 'expect': 'OK'}], + 'observe': {'health': {}}, + } + suite = Suite(profile, root / 'profile.json', root) + with patch('regression_local_cases.run_case', side_effect=ScenarioUnavailable('Missing user credential')): + result = run_smoke(suite) + self.assertEqual(result['status'], {'skipped': 1}) + self.assertEqual(result['errors'], result['incomplete']) + self.assertIn('Missing user credential', result['incomplete'][0]) + self.assertFalse(result['cases'][0].get('assertions')) + self.assertEqual(json.loads((root / 'cases.json').read_text())[0]['status'], 'skipped') + + def test_cleanup_failure_remains_fail_after_unknown_request(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with sqlite3.connect(root / 'ledger.db') as connection: + connection.execute('CREATE TABLE agent_run (id INTEGER)') + profile = { + 'version': 1, + 'cli': {'config': 'context.yaml'}, + 'smoke': {'ledger_db': 'ledger.db'}, + 'cases': [{'name': 'agent_direct_execution', 'scenario': 'agent_direct_execution', 'expect': 'OK'}], + 'observe': {'health': {}}, + } + suite = Suite(profile, root / 'profile.json', root) + + def fail(suite, case, row): + row['cleanup_errors'] = ['Agent deletion returned HTTP 500'] + raise RuntimeError('Request outcome unknown') + + with patch('regression_local_cases.run_case', side_effect=fail): + result = run_smoke(suite) + suite.finish(result) + self.assertEqual(result['outcome'], 'FAIL') + self.assertEqual(result['cases'][0]['status'], 'unknown') + self.assertTrue(any('cleanup failed' in message for message in result['errors'])) + + def test_expected_failure_does_not_allow_extra_or_running_runs(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'ledger.db' + with sqlite3.connect(path) as connection: + connection.execute( + 'CREATE TABLE agent_run (id INTEGER, run_id TEXT, workspace_id TEXT, conversation_id TEXT, status TEXT)' + ) + connection.execute("INSERT INTO agent_run VALUES (1, 'owned', 'ws', 'session', 'failed')") + args = (path, 'ws', 0, {'session'}, {'owned'}) + self.assertFalse(reconcile_runs(*args)['matched']) + expected = {'owned': 'failed'} + self.assertTrue(reconcile_runs(*args, expected_statuses=expected)['matched']) + with sqlite3.connect(path) as connection: + connection.execute("UPDATE agent_run SET status='running'") + self.assertFalse(reconcile_runs(*args, expected_statuses=expected)['matched']) + with sqlite3.connect(path) as connection: + connection.execute("UPDATE agent_run SET status='failed'") + connection.execute("INSERT INTO agent_run VALUES (2, 'extra', 'ws', 'session', 'completed')") + result = reconcile_runs(*args, expected_statuses=expected) + self.assertFalse(result['matched']) + self.assertEqual(result['extra'], ['extra']) + with self.assertRaises(ValueError): + reconcile_runs(*args, expected_statuses={'owned': 'running'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_regression_resource_cases.py b/tools/test_regression_resource_cases.py new file mode 100644 index 000000000..3e039ee6b --- /dev/null +++ b/tools/test_regression_resource_cases.py @@ -0,0 +1,212 @@ +"""Challenge public resource lifecycle oracles and their cleanup without starting services.""" + +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from regression_local_cases import ScenarioUnavailable +from regression_resource_cases import run_case + + +def success(data=None): + return 200, {'code': 0, 'data': data or {}} + + +class ResourceCasesTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.suite = SimpleNamespace( + output=self.root, + env={'LANGBOT_QA_USER_TOKEN': 'private-user-token'}, + secrets=[], + variables={'workspace_uuid': 'workspace', 'resource_knowledge_base': 'owned-kb'}, + profile={ + 'resources': [ + { + 'id': 'chat_pipeline', + 'body': {'config': {'ai': {'runner': {'id': 'runner'}, 'runner_config': {'runner': {}}}}}, + } + ] + }, + write=lambda name, value: (self.root / name).write_text(json.dumps(value)), + ) + + def test_missing_management_session_skips_without_creating_credentials(self): + self.suite.env.clear() + with patch('regression_resource_cases.api') as request, self.assertRaises(ScenarioUnavailable): + run_case(self.suite, {'scenario': 'auth_revocation'}, {}) + request.assert_not_called() + + def test_revocation_rejects_still_authorized_key_and_never_persists_secrets(self): + for stale_authorization in (False, True): + with self.subTest(stale_authorization=stale_authorization): + key = {'id': 7, 'uuid': 'managed-key', 'status': 'active', 'secret_available': False} + row = {'after_id': 0} + + def request(suite, method, path, body=None, headers=None): + if method == 'POST': + return success({'key': {**key, 'key': 'private-child-secret', 'secret_available': True}}) + if method == 'DELETE': + key['status'] = 'revoked' + return success() + if path == '/api/v1/system/context': + self.assertEqual(headers, {'X-API-Key': 'private-child-secret'}) + return success({'workspace_uuid': 'workspace', 'api_key_id': 'managed-key'}) + if path == '/api/v1/pipelines': + return ( + success({'pipelines': []}) + if key['status'] == 'active' or stale_authorization + else (401, {'code': -1}) + ) + return success({'key': key}) + + with patch('regression_resource_cases.api', side_effect=request): + case = {'scenario': 'auth_revocation', 'expect': 'REVOKED'} + if stale_authorization: + with self.assertRaisesRegex(ValueError, 'still authorizes'): + run_case(self.suite, case, row) + self.assertEqual(row['assertions']['credential_revoked'], 'failed') + else: + self.assertEqual(run_case(self.suite, case, row)['status'], 'revoked') + self.assertEqual(key['status'], 'revoked') + self.assertEqual(row['assertions']['key_cleanup'], 'passed') + evidence = json.dumps(row) + (self.root / row['resource_journal']).read_text() + self.assertNotIn('private-child-secret', evidence) + self.assertNotIn('private-user-token', evidence) + + def test_direct_agent_records_run_and_cleans_after_failed_public_event_oracle(self): + for missing_event in (False, True): + with self.subTest(missing_event=missing_event): + state, row = {}, {'after_id': 12} + + def request(suite, method, path, body=None, headers=None): + if method == 'POST' and path == '/api/v1/agents': + state.update(uuid='own-agent', name=body['name']) + return success({'uuid': 'own-agent'}) + if path.endswith('/debug'): + state['conversation'] = body['conversation_id'] + return success({'conversation_id': state['conversation'], 'final_text': 'DIRECT_OK'}) + run = { + 'run_id': 'own-run', + 'conversation_id': state.get('conversation'), + 'status': 'completed', + 'finished_at': 1, + } + if path.endswith('/runs'): + return success({'items': [run]}) + if path.endswith('/events'): + return success( + { + 'run': run, + 'items': [] if missing_event else [{'type': 'run.completed', 'run_id': 'own-run'}], + } + ) + if method == 'DELETE': + self.assertEqual(path, '/api/v1/agents/own-agent') + state['deleted'] = True + return success() + return (404, {'code': -1}) if state.get('deleted') else success({'agent': state}) + + with patch('regression_resource_cases.api', side_effect=request): + case = {'scenario': 'agent_direct_execution', 'expect': 'DIRECT_OK'} + if missing_event: + with self.assertRaisesRegex(ValueError, 'completion event'): + run_case(self.suite, case, row) + else: + self.assertEqual(run_case(self.suite, case, row)['run_id'], 'own-run') + self.assertEqual( + row['scenario_runs'], + [ + { + 'run_id': 'own-run', + 'conversation_id': state['conversation'], + 'after_id': 12, + 'expected_status': 'completed', + } + ], + ) + self.assertTrue(state['deleted']) + self.assertEqual(row['assertions']['agent_cleanup'], 'passed') + + def test_unknown_agent_create_recovers_owned_identity_without_retrying_creation(self): + calls, state, row = [], {}, {'after_id': 0} + + def request(suite, method, path, body=None, headers=None): + calls.append((method, path)) + if method == 'POST': + state.update(uuid='own-agent', name=body['name'], kind='agent') + raise ConnectionResetError('creation response lost') + if path == '/api/v1/agents': + return success({'agents': [state, {'uuid': 'unrelated', 'name': 'other', 'kind': 'agent'}]}) + if method == 'DELETE': + self.assertEqual(path, '/api/v1/agents/own-agent') + state['deleted'] = True + return success() + return (404, {'code': -1}) if state.get('deleted') else success({'agent': state}) + + with patch('regression_resource_cases.api', side_effect=request), self.assertRaises(ConnectionResetError): + run_case(self.suite, {'scenario': 'agent_direct_execution', 'expect': 'OK'}, row) + self.assertEqual(sum(method == 'POST' for method, _ in calls), 1) + self.assertEqual(row['owned_resources'][0]['state'], 'deleted') + + def test_delete_requires_retrieval_absence_and_preserves_control_document(self): + for stale_vectors in (False, True): + with self.subTest(stale_vectors=stale_vectors): + documents, row = [], {'after_id': 0} + + def ingest(args): + content = Path(args[args.index('--file') + 1]).read_text() + index = len(documents) + documents.append( + { + 'uuid': f'file-{index}', + 'engine_document_id': f'engine-{index}', + 'status': 'completed', + 'content': content, + 'deleted': False, + } + ) + return {} + + def request(suite, method, path, body=None, headers=None): + if method == 'DELETE': + target = next(document for document in documents if path.endswith('/' + document['uuid'])) + target['deleted'] = True + return success() + if method == 'GET': + return success({'files': [document for document in documents if not document['deleted']]}) + return success( + { + 'results': [ + { + 'metadata': {'file_id': document['engine_document_id']}, + 'content': [{'type': 'text', 'text': document['content']}], + } + for document in documents + if not document['deleted'] or stale_vectors + ] + } + ) + + self.suite.call = ingest + with patch('regression_resource_cases.api', side_effect=request): + case = {'scenario': 'retrieval_delete_visibility', 'expect': 'DELETED'} + if stale_vectors: + with self.assertRaisesRegex(ValueError, 'remains visible'): + run_case(self.suite, case, row) + self.assertEqual(row['assertions']['retrieval_deleted'], 'failed') + else: + self.assertEqual(run_case(self.suite, case, row)['result'], 'DELETED') + self.assertEqual(row['assertions']['retrieval_control'], 'passed') + self.assertTrue(all(document['deleted'] for document in documents)) + self.assertEqual(row['assertions']['document_cleanup'], 'passed') + self.assertEqual(list(self.root.glob('resource-documents-*')), []) + + +if __name__ == '__main__': + unittest.main()