Files

165 lines
8.6 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Exercise identity SQL against acceptance PostgreSQL, always rolling back."""
import argparse
import json
import urllib.error
import urllib.request
from pathlib import Path
from apply_weknora_identities import sql_run, target_snapshot
from weknora_category_etl import literal, save_json
from weknora_identity_etl import (IDENTITY_LEDGER, ORGANIZATION_LEDGER, TARGET_TENANT,
identity_sql, identity_verification_sql)
def rejected_case(name, mutation, expected_message, import_sql):
# Roll back both the simulated conflict and the attempted import, even when
# the expected rejection is caught in the inner subtransaction.
return f"""
DO $test_conflict$
DECLARE blocked boolean := false;
BEGIN
BEGIN
{mutation}
BEGIN
EXECUTE {literal(import_sql)};
EXCEPTION WHEN raise_exception THEN
IF position({literal(expected_message)} in SQLERRM) > 0 THEN blocked := true; ELSE RAISE; END IF;
END;
IF NOT blocked THEN RAISE EXCEPTION 'Expected conflict was not rejected'; END IF;
RAISE EXCEPTION USING ERRCODE='Z9001', MESSAGE='rollback accepted test';
EXCEPTION WHEN SQLSTATE 'Z9001' THEN NULL;
END;
END $test_conflict$;
SELECT json_build_object('check',{literal(name)},'passed',true);
"""
def regression_sql(manifest):
query = identity_sql(manifest)
uid = next(u["target_id"] for u in manifest["users"] if u["action"] == "create_suspended")
sql = f"""
BEGIN ISOLATION LEVEL SERIALIZABLE;
SET LOCAL statement_timeout='30s';
SET LOCAL lock_timeout='5s';
{query}
CREATE TEMP TABLE _test_users ON COMMIT DROP AS SELECT * FROM users;
CREATE TEMP TABLE _test_members ON COMMIT DROP AS SELECT * FROM tenant_members;
CREATE TEMP TABLE _test_identities ON COMMIT DROP AS SELECT * FROM {IDENTITY_LEDGER};
CREATE TEMP TABLE _test_organizations ON COMMIT DROP AS SELECT * FROM {ORGANIZATION_LEDGER};
{query}
DO $test_repeat$
BEGIN
IF EXISTS (SELECT * FROM users EXCEPT SELECT * FROM _test_users)
OR EXISTS (SELECT * FROM _test_users EXCEPT SELECT * FROM users)
OR EXISTS (SELECT * FROM tenant_members EXCEPT SELECT * FROM _test_members)
OR EXISTS (SELECT * FROM _test_members EXCEPT SELECT * FROM tenant_members)
OR EXISTS (SELECT * FROM {IDENTITY_LEDGER} EXCEPT SELECT * FROM _test_identities)
OR EXISTS (SELECT * FROM {ORGANIZATION_LEDGER} EXCEPT SELECT * FROM _test_organizations)
THEN RAISE EXCEPTION 'Exact repeat changed stored rows'; END IF;
END $test_repeat$;
SELECT json_build_object('check','exact_repeat_is_noop','passed',true);
"""
cases = [
("foreign_tenant_rejected", f"UPDATE users SET tenant_id={TARGET_TENANT + 1} WHERE id={literal(uid)};", "ID/provenance collision"),
("cross_tenant_membership_rejected", f"INSERT INTO tenant_members (user_id,tenant_id,role,status) VALUES ({literal(uid)},{TARGET_TENANT + 1},'viewer','active');", "unexpected cross-tenant membership"),
("changed_mapping_rejected", f"UPDATE {IDENTITY_LEDGER} SET target_user_id='wrong-mapping' WHERE target_user_id={literal(uid)};", "Durable identity mapping conflict"),
("deleted_account_not_revived", f"UPDATE users SET deleted_at=now() WHERE id={literal(uid)};", "ID/provenance collision"),
("elevated_role_rejected", f"UPDATE tenant_members SET role='admin' WHERE user_id={literal(uid)} AND tenant_id={TARGET_TENANT};", "membership does not match"),
]
target = next(u for u in manifest["users"] if u["target_id"] == uid)
other_uid = next(u["target_id"] for u in manifest["users"] if u["action"] == "create_suspended" and u["target_id"] != uid)
for field in ("username", "email"):
mutation = f"DELETE FROM {IDENTITY_LEDGER} WHERE target_user_id={literal(uid)};"
mutation += f"UPDATE users SET {field}='etl-conflict-vacated@migration.invalid' WHERE id={literal(uid)};"
mutation += f"UPDATE users SET {field}={literal(target['target_' + field].upper())} WHERE id={literal(other_uid)};"
cases.append((f"normalized_{field}_collision_rejected", mutation, "username/email collision"))
for name, mutation, expected in cases:
sql += rejected_case(name, mutation, expected, query)
sql += f"""
UPDATE users SET preferences='{{}}'::jsonb,username='etl-contact-preservation-check',
email='etl-contact-preservation-check@migration.invalid',is_active=true,
password_hash='!rollback-only-reset-check' WHERE id={literal(uid)};
UPDATE tenant_members SET status='active' WHERE user_id={literal(uid)} AND tenant_id={TARGET_TENANT};
CREATE TEMP TABLE _test_activated_users ON COMMIT DROP AS SELECT * FROM users;
CREATE TEMP TABLE _test_activated_members ON COMMIT DROP AS SELECT * FROM tenant_members;
{query}
DO $test_activation$
BEGIN
IF EXISTS (SELECT * FROM users EXCEPT SELECT * FROM _test_activated_users)
OR EXISTS (SELECT * FROM tenant_members EXCEPT SELECT * FROM _test_activated_members)
THEN RAISE EXCEPTION 'Repeat overwrote native profile or activation'; END IF;
END $test_activation$;
SELECT json_build_object('check','native_preferences_contacts_and_activation_preserved','passed',true);
SELECT json_build_object('check','durable_mapping_and_account_scope','passed',bool_and(
database_matches AND organization_mapping_matches AND identity_mapping_matches
AND account_scope_matches AND membership_matches)) FROM ({identity_verification_sql(manifest).rstrip().rstrip(';')}) checks;
ROLLBACK;
"""
return sql
def api_checks(manifest, credentials_path):
credentials = json.loads(credentials_path.read_text())
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(path, token=None, payload=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request("http://127.0.0.1:18080" + path, headers=headers,
data=json.dumps(payload).encode() if payload else None)
try:
with opener.open(req, timeout=20) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, {}
checks = []
status, auth = request("/api/v1/auth/login", payload={key: credentials[key] for key in ("email", "password")})
passed = status == 200 and auth.get("active_tenant", {}).get("id") == TARGET_TENANT
checks.append({"check": "admin_login_in_acceptance_tenant", "passed": passed})
if not passed:
return checks
status, data = request(f"/api/v1/tenants/{TARGET_TENANT}/members?page=1&page_size=100", auth["token"])
members = {m["user_id"]: m for m in data.get("data", {}).get("members", [])}
checks.append({"check": "admin_member_list_available", "passed": status == 200})
for item in manifest["users"]:
member = members.get(item["target_id"], {})
expected_status = "active" if item["action"] == "map_existing_admin" else "suspended"
checks.append({"check": f"api_source_user_{item['source_user']['id']}_membership",
"passed": member.get("role") == item["target_membership_role"] and member.get("status") == expected_status})
if item["action"] == "create_suspended":
status, response = request("/api/v1/auth/login", payload={"email": item["target_email"], "password": "etl-disabled-login-probe"})
checks.append({"check": f"api_source_user_{item['source_user']['id']}_login_denied",
"passed": status in (401, 403) and not response.get("token")})
status, _ = request(f"/api/v1/tenants/{TARGET_TENANT}/members")
checks.append({"check": "anonymous_membership_list_denied", "passed": status in (401, 403)})
return checks
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--credentials", type=Path)
args = parser.parse_args()
if args.out.exists():
parser.error("Output already exists")
manifest = json.loads(args.manifest.read_text())
before = target_snapshot()
output = sql_run(regression_sql(manifest))
checks = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
after = target_snapshot()
checks.append({"check": "all_test_changes_rolled_back", "passed": before == after})
if args.credentials:
checks.extend(api_checks(manifest, args.credentials))
result = {"passed": all(c["passed"] for c in checks), "committed": False, "checks": checks}
save_json(args.out, result)
print(json.dumps(result))
return 0 if result["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())