273 lines
16 KiB
Python
273 lines
16 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Verify historical workbench imports through pinned local acceptance HTTP.
|
||
|
|
|
||
|
|
Run only after the business import and read-only protection deployment finish.
|
||
|
|
Reports contain only boolean checks and counts, never resource or login bodies.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
from collections import Counter
|
||
|
|
import json
|
||
|
|
import secrets
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
import uuid
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from apply_weknora_business import target_snapshot
|
||
|
|
from apply_weknora_identities import sql_run
|
||
|
|
from weknora_business_etl import LEDGER, RESOURCE_KINDS, SOURCE
|
||
|
|
from weknora_category_etl import literal, save_json
|
||
|
|
from weknora_identity_etl import TARGET_DATABASE, TARGET_TENANT
|
||
|
|
|
||
|
|
|
||
|
|
BASE_URL = 'http://127.0.0.1:18080'
|
||
|
|
ROOT = '/api/v1/workbench/resources/'
|
||
|
|
EXPECTED_COUNTS = {'canvas': 3, 'trainingmap': 8, 'campaign': 2}
|
||
|
|
ACTIONS = {
|
||
|
|
'flows': ('generate',), # Flows have no executable action in the native API.
|
||
|
|
'training-maps': ('generate', 'revise', 'generate-day', 'confirm-day', 'confirm'),
|
||
|
|
'content-tasks': ('decompose', 'generate', 'revise', 'accept', 'skip', 'review', 'compose'),
|
||
|
|
}
|
||
|
|
READ_ONLY_TEXT = '\u53ea\u8bfb\u5386\u53f2\u8d44\u6599'
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument('--credentials', type=Path, required=True)
|
||
|
|
parser.add_argument('--out', type=Path, required=True)
|
||
|
|
args = parser.parse_args()
|
||
|
|
if args.out.exists():
|
||
|
|
parser.error('Report exists')
|
||
|
|
|
||
|
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||
|
|
checks, counts = {}, Counter()
|
||
|
|
before, fixture_user, fixture_tenant = None, None, None
|
||
|
|
|
||
|
|
def check(name, passed):
|
||
|
|
checks[name] = checks.get(name, True) and bool(passed)
|
||
|
|
counts[name] += 1
|
||
|
|
return bool(passed)
|
||
|
|
|
||
|
|
def require(name, passed):
|
||
|
|
if not check(name, passed):
|
||
|
|
raise RuntimeError('Acceptance precondition failed')
|
||
|
|
|
||
|
|
def request(path, token=None, data=None, method=None, tenant=TARGET_TENANT):
|
||
|
|
headers = {'Content-Type': 'application/json'}
|
||
|
|
if tenant is not None:
|
||
|
|
headers['X-Tenant-ID'] = str(tenant)
|
||
|
|
if token:
|
||
|
|
headers['Authorization'] = 'Bearer ' + token
|
||
|
|
body = json.dumps(data, ensure_ascii=False).encode() if data is not None else None
|
||
|
|
if body is not None and len(body) > 2 * 1024 * 1024:
|
||
|
|
raise RuntimeError('Verification payload exceeds the native request limit')
|
||
|
|
req = urllib.request.Request(BASE_URL + path, headers=headers, data=body, method=method)
|
||
|
|
counts['http_requests'] += 1
|
||
|
|
try:
|
||
|
|
response = opener.open(req, timeout=30)
|
||
|
|
except urllib.error.HTTPError as error:
|
||
|
|
response = error
|
||
|
|
with response:
|
||
|
|
payload = json.load(response)
|
||
|
|
if not isinstance(payload, dict):
|
||
|
|
raise RuntimeError('Unexpected acceptance response shape')
|
||
|
|
return response.status, payload
|
||
|
|
|
||
|
|
def listed(kind, token):
|
||
|
|
result = {}
|
||
|
|
for page in range(1, 1001):
|
||
|
|
status, body = request(ROOT + kind + f'?page={page}&page_size=100', token)
|
||
|
|
if status != 200 or not isinstance(body.get('data'), list):
|
||
|
|
return False, result
|
||
|
|
rows, total = body['data'], body.get('total')
|
||
|
|
if type(total) is not int or total < 0:
|
||
|
|
return False, result
|
||
|
|
old_count = len(result)
|
||
|
|
for row in rows:
|
||
|
|
result[row['id']] = row
|
||
|
|
if len(result) >= total:
|
||
|
|
return len(result) == total, result
|
||
|
|
if len(result) == old_count:
|
||
|
|
return False, result
|
||
|
|
return False, result
|
||
|
|
|
||
|
|
def denied(name, path, token, data=None, method=None, tenant=TARGET_TENANT,
|
||
|
|
statuses=(403, 404), readonly=False):
|
||
|
|
status, body = request(path, token, data, method, tenant)
|
||
|
|
passed = status in statuses and (not readonly or READ_ONLY_TEXT in body.get('message', ''))
|
||
|
|
check(name, passed)
|
||
|
|
# A successful mutation ends the run immediately; never attempt repairs.
|
||
|
|
if method in ('PUT', 'DELETE', 'POST') and 200 <= status < 300:
|
||
|
|
raise RuntimeError('A protected mutation unexpectedly succeeded')
|
||
|
|
|
||
|
|
try:
|
||
|
|
before = target_snapshot()
|
||
|
|
require('pinned_acceptance_database', before['database'] == TARGET_DATABASE)
|
||
|
|
expected = json.loads(sql_run(
|
||
|
|
f"SELECT coalesce(json_agg(json_build_object('kind',source_kind,'id',target_id,"
|
||
|
|
f"'native',native_snapshot) ORDER BY source_kind,source_id),'[]') FROM {LEDGER} "
|
||
|
|
f"WHERE source_system={literal(SOURCE)} AND source_organization_id=1 "
|
||
|
|
f"AND target_tenant_id={TARGET_TENANT} AND source_kind IN ('canvas','trainingmap','campaign');"))
|
||
|
|
require('exact_13_migrated_resources_in_ledger',
|
||
|
|
Counter(item['kind'] for item in expected) == EXPECTED_COUNTS
|
||
|
|
and len({item['id'] for item in expected}) == 13)
|
||
|
|
for source_kind, count in EXPECTED_COUNTS.items():
|
||
|
|
counts['migrated_' + RESOURCE_KINDS[source_kind]] = count
|
||
|
|
require('all_13_native_snapshots_are_read_only', all(
|
||
|
|
item['native']['data'].get('legacy_import', {}).get('read_only') is True
|
||
|
|
and item['native']['tenant_id'] == TARGET_TENANT
|
||
|
|
and item['native']['id'] == item['id']
|
||
|
|
and item['native']['kind'] == RESOURCE_KINDS[item['kind']]
|
||
|
|
for item in expected))
|
||
|
|
|
||
|
|
credentials = json.loads(args.credentials.read_text())
|
||
|
|
status, auth = request('/api/v1/auth/login', data={k: credentials[k] for k in ('email', 'password')})
|
||
|
|
require('administrator_login_succeeded', status == 200 and bool(auth.get('token')))
|
||
|
|
token, administrator_id = auth['token'], auth.get('user', {}).get('id')
|
||
|
|
require('administrator_identity_available', bool(administrator_id))
|
||
|
|
counts['migrated_distinct_owners'] = len({item['native']['owner_id'] for item in expected})
|
||
|
|
counts['migrated_administrator_owned_resources'] = sum(
|
||
|
|
item['native']['owner_id'] == administrator_id for item in expected)
|
||
|
|
counts['migrated_other_owner_resources'] = len(expected) - counts['migrated_administrator_owned_resources']
|
||
|
|
|
||
|
|
for kind in RESOURCE_KINDS.values():
|
||
|
|
ok, resources = listed(kind, token)
|
||
|
|
selected = [item for item in expected if item['native']['kind'] == kind]
|
||
|
|
require('administrator_lists_all_migrated_resources',
|
||
|
|
ok and all(item['id'] in resources for item in selected))
|
||
|
|
denied('anonymous_list_denied', ROOT + kind, None, statuses=(401, 403))
|
||
|
|
|
||
|
|
for item in expected:
|
||
|
|
native = item['native']
|
||
|
|
path = ROOT + native['kind'] + '/' + item['id']
|
||
|
|
status, body = request(path, token)
|
||
|
|
current = body.get('data', {})
|
||
|
|
require('native_data_owner_status_and_version_match', status == 200 and all(
|
||
|
|
current.get(field) == native[field]
|
||
|
|
for field in ('id', 'tenant_id', 'owner_id', 'kind', 'name', 'status', 'version', 'data')))
|
||
|
|
status, body = request(path + '/jobs?page_size=100', token)
|
||
|
|
require('imported_resources_have_no_jobs', status == 200 and body.get('data') == [] and body.get('total') == 0)
|
||
|
|
for suffix in ('', '/jobs?page_size=100'):
|
||
|
|
denied('anonymous_resource_and_jobs_read_denied', path + suffix, None, statuses=(401, 403))
|
||
|
|
|
||
|
|
payload = {key: native[key] for key in ('name', 'data', 'version')}
|
||
|
|
owned = native['owner_id'] == administrator_id
|
||
|
|
# PUT checks historical protection before locking. DELETE and supported
|
||
|
|
# actions lock by owner first, even for a tenant administrator.
|
||
|
|
denied('all_13_updates_rejected_as_read_only', path, token, payload, 'PUT', statuses=(400,), readonly=True)
|
||
|
|
for detach, label in (('', 'deletes'), ('?detach=true', 'detaching_deletes')):
|
||
|
|
denied(('owned_' + label + '_rejected_as_read_only') if owned else
|
||
|
|
('other_owner_' + label + '_rejected_by_ownership'), path + detach, token,
|
||
|
|
method='DELETE', statuses=(400,) if owned else (403, 404), readonly=owned)
|
||
|
|
for action in ACTIONS[native['kind']]:
|
||
|
|
parameters = {'version': native['version'], 'day': 1}
|
||
|
|
if action == 'revise':
|
||
|
|
parameters['advice'] = 'Verification only; preserve historical content.'
|
||
|
|
segments = native['data'].get('segments', [])
|
||
|
|
if segments:
|
||
|
|
parameters['segment_id'] = segments[0]['id']
|
||
|
|
is_flow = native['kind'] == 'flows'
|
||
|
|
action_check = 'flow_actions_unsupported' if is_flow else (
|
||
|
|
'owned_supported_actions_rejected_as_read_only' if owned else
|
||
|
|
'other_owner_supported_actions_rejected_by_ownership')
|
||
|
|
denied(action_check, path + '/actions/' + action, token, parameters, 'POST',
|
||
|
|
statuses=(400,) if is_flow or owned else (403, 404), readonly=owned and not is_flow)
|
||
|
|
denied('anonymous_update_denied', path, None, payload, 'PUT', statuses=(401, 403))
|
||
|
|
denied('anonymous_delete_denied', path, None, method='DELETE', statuses=(401, 403))
|
||
|
|
denied('anonymous_action_denied', path + '/actions/generate', None,
|
||
|
|
{'version': native['version']}, 'POST', statuses=(401, 403))
|
||
|
|
require('resources_and_execution_unchanged_after_admin_checks', before == target_snapshot())
|
||
|
|
|
||
|
|
suffix = secrets.token_hex(6)
|
||
|
|
password = secrets.token_urlsafe(18) + 'aA1!'
|
||
|
|
email = 'business-check-' + suffix + '@migration.invalid'
|
||
|
|
existing_tenants = set(json.loads(sql_run("SELECT coalesce(json_agg(id),'[]') FROM tenants;")))
|
||
|
|
status, body = request('/api/v1/system/admin/users/create', token, {
|
||
|
|
'username': 'business-check-' + suffix, 'email': email, 'password': password})
|
||
|
|
candidate = body.get('user', {}).get('id')
|
||
|
|
require('temporary_user_created', status == 201 and candidate and str(uuid.UUID(candidate)) == candidate)
|
||
|
|
fixture_user = candidate
|
||
|
|
home = int(sql_run(f"SELECT tenant_id FROM users WHERE id={literal(fixture_user)};"))
|
||
|
|
if home and home not in existing_tenants:
|
||
|
|
fixture_tenant = home
|
||
|
|
require('temporary_user_home_is_new_or_tenantless', home == 0 or fixture_tenant == home)
|
||
|
|
sql_run(f"INSERT INTO tenant_members(user_id,tenant_id,role,status) VALUES "
|
||
|
|
f"({literal(fixture_user)},{TARGET_TENANT},'contributor','active');")
|
||
|
|
status, auth = request('/api/v1/auth/login', data={'email': email, 'password': password})
|
||
|
|
require('contributor_login_succeeded', status == 200 and bool(auth.get('token')))
|
||
|
|
member_token = auth['token']
|
||
|
|
fixture_scope = json.loads(sql_run(f"SELECT json_build_object("
|
||
|
|
f"'is_admin',is_system_admin,'all_tenants',can_access_all_tenants,"
|
||
|
|
f"'memberships',(SELECT count(*) FROM tenant_members WHERE user_id=u.id AND deleted_at IS NULL),"
|
||
|
|
f"'has_personal_membership',EXISTS(SELECT 1 FROM tenant_members WHERE user_id=u.id "
|
||
|
|
f"AND tenant_id={fixture_tenant or 0} AND role='owner' AND status='active' AND deleted_at IS NULL),"
|
||
|
|
f"'is_contributor',EXISTS(SELECT 1 FROM tenant_members WHERE user_id=u.id "
|
||
|
|
f"AND tenant_id={TARGET_TENANT} AND role='contributor' AND status='active' AND deleted_at IS NULL)) "
|
||
|
|
f"FROM users u WHERE id={literal(fixture_user)};"))
|
||
|
|
require('temporary_user_is_isolated_contributor', fixture_scope == {
|
||
|
|
'is_admin': False, 'all_tenants': False, 'memberships': 1 + bool(fixture_tenant),
|
||
|
|
'has_personal_membership': bool(fixture_tenant), 'is_contributor': True})
|
||
|
|
|
||
|
|
for kind in RESOURCE_KINDS.values():
|
||
|
|
ok, resources = listed(kind, member_token)
|
||
|
|
require('contributor_lists_no_other_owners_resources', ok and not resources)
|
||
|
|
denied('cross_tenant_list_denied', ROOT + kind, member_token, tenant=10060)
|
||
|
|
for item in expected:
|
||
|
|
native = item['native']
|
||
|
|
path = ROOT + native['kind'] + '/' + item['id']
|
||
|
|
payload = {key: native[key] for key in ('name', 'data', 'version')}
|
||
|
|
for suffix in ('', '/jobs?page_size=100'):
|
||
|
|
denied('contributor_direct_resource_and_jobs_read_denied', path + suffix, member_token)
|
||
|
|
denied('cross_tenant_direct_resource_and_jobs_read_denied', path + suffix, member_token, tenant=10060)
|
||
|
|
denied('contributor_update_denied', path, member_token, payload, 'PUT')
|
||
|
|
denied('contributor_delete_denied', path, member_token, method='DELETE')
|
||
|
|
denied('cross_tenant_update_denied', path, member_token, payload, 'PUT', tenant=10060)
|
||
|
|
denied('cross_tenant_delete_denied', path, member_token, method='DELETE', tenant=10060)
|
||
|
|
denied('cross_tenant_action_denied', path + '/actions/generate', member_token,
|
||
|
|
{'version': native['version']}, 'POST', tenant=10060)
|
||
|
|
if native['kind'] != 'flows':
|
||
|
|
denied('contributor_direct_action_denied', path + '/actions/generate', member_token,
|
||
|
|
{'version': native['version']}, 'POST')
|
||
|
|
else:
|
||
|
|
denied('contributor_flow_action_unsupported', path + '/actions/generate', member_token,
|
||
|
|
{'version': native['version']}, 'POST', statuses=(400,))
|
||
|
|
check('all_api_checks_completed', True)
|
||
|
|
except Exception:
|
||
|
|
# Transport/database exceptions can contain private paths or response data.
|
||
|
|
check('all_api_checks_completed', False)
|
||
|
|
finally:
|
||
|
|
if fixture_user:
|
||
|
|
try:
|
||
|
|
disable_tenant = (f"UPDATE tenants SET status='inactive',updated_at=now() "
|
||
|
|
f"WHERE id={fixture_tenant}; ") if fixture_tenant else ''
|
||
|
|
sql_run(f"BEGIN; DELETE FROM tenant_members WHERE user_id={literal(fixture_user)}; "
|
||
|
|
f"DELETE FROM auth_tokens WHERE user_id={literal(fixture_user)}; "
|
||
|
|
f"UPDATE users SET is_active=false,updated_at=now() WHERE id={literal(fixture_user)}; "
|
||
|
|
+ disable_tenant + "COMMIT;")
|
||
|
|
cleaned = sql_run(f"SELECT NOT is_active AND NOT EXISTS(SELECT 1 FROM tenant_members "
|
||
|
|
f"WHERE user_id=u.id) AND NOT EXISTS(SELECT 1 FROM auth_tokens WHERE user_id=u.id) "
|
||
|
|
f"FROM users u WHERE id={literal(fixture_user)};") == 't'
|
||
|
|
check('temporary_user_deactivated_membership_and_tokens_removed', cleaned)
|
||
|
|
if fixture_tenant:
|
||
|
|
check('temporary_personal_tenant_deactivated', sql_run(
|
||
|
|
f"SELECT status='inactive' FROM tenants WHERE id={fixture_tenant};") == 't')
|
||
|
|
except Exception:
|
||
|
|
check('temporary_user_deactivated_membership_and_tokens_removed', False)
|
||
|
|
if before is not None:
|
||
|
|
try:
|
||
|
|
check('all_existing_resources_jobs_events_revisions_assets_and_knowledge_unchanged', before == target_snapshot())
|
||
|
|
except Exception:
|
||
|
|
check('all_existing_resources_jobs_events_revisions_assets_and_knowledge_unchanged', False)
|
||
|
|
result = {'passed': bool(checks) and all(checks.values()), 'checks': checks, 'counts': dict(counts),
|
||
|
|
'fixture_user_created': fixture_user is not None,
|
||
|
|
'fixture_personal_tenant_created': fixture_tenant is not None}
|
||
|
|
save_json(args.out, result)
|
||
|
|
print(json.dumps(result))
|
||
|
|
return 0 if result['passed'] else 1
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
raise SystemExit(main())
|