Files
Claude VM a6c03a091e initial: split from gov-agreg — vreau.digital standalone platform
Moved from gov-agreg/src/pages/achizitii/* to root (drop prefix).
- 22 pages migrated, 127 files total
- All internal links: /achizitii/X → /X (176 occurrences fixed)
- AchizitiiLayout subnav rewritten: /X paths, top-right link to vreaudigital.ro hub
- BaseLayout new (vreau.digital branding, OG tags, site URL)
- astro.config.mjs: site https://vreau.digital, server output (was static)
- docker-compose: port 5096 (vreaudigital is 5095), container vreau-digital
- deploy.sh: paths /opt/vreau-digital, log /var/log/vreau-digital-deploy.log

Backend shared with gov-agreg:
- PostgreSQL satra (same schemas: seap, firms, anaf, anre, ...)
- Photon, Martin tiles
- Infisical /vreaudigital path (DATABASE_URL etc. shared)

build: PASS (npx astro check 0 errors, npm run build 5s vite + 10s server)
2026-05-13 00:10:32 +03:00

381 lines
14 KiB
Python

#!/usr/bin/env python3
"""
SEAP WSP validation suite — runs all tests needed before building the
production sync. Reads SEAP_USER/SEAP_PASS/SEAP_CERT_KEY from env
(loaded from Infisical /seap), extracts cert+key transiently to /dev/shm,
shreds at exit.
Tests:
T1: every SU_* unprefixed operation (public-data feeds)
T2: every Su* operation (Beletage-scoped)
T3: pagination + volume probe (1 day vs 7 day windows)
T4: rate-limit behavior (sustained calls)
T5: SuContracts for Beletage's own data
Usage:
source ~/Code/claude-dotfiles/load-infisical-path.sh /seap
cd services/seap-scraper
./.venv/bin/python wsp_validate.py
"""
import atexit
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.ssl_ import create_urllib3_context
PROD_URL = 'https://e-licitatie.ro:8883/Pub'
CERT_DIR = Path('/dev/shm')
CRT_PATH = CERT_DIR / f'wsp_{os.getpid()}.crt'
KEY_PATH = CERT_DIR / f'wsp_{os.getpid()}.key'
P12_PATH = Path(__file__).parent / 'credentials' / '50076FB3826FADA540ACFB19.p12'
# Operations grouped by contract
SUPPLIER_PUB_OPS = [ # SU_* — public data accessible via supplier WSP
'SU_PiNotices', 'SU_CNotices', 'SU_CaNotices', 'SU_DCNotices',
'SU_PCNotices', 'SU_RfqInvitations', 'SU_RfqNotices',
'SU_RdcNotices', 'SU_EAProcedure', 'SU_ENotices',
]
SUPPLIER_OWN_OPS = [ # Su* + Catalog_* — Beletage-scoped
'SuContracts', 'SuInvoices', 'SuDirectAcquisitions',
'Catalog_ListItems',
]
# ── Cert handling ──
def setup_certs():
pwd = os.environ['SEAP_CERT_KEY']
subprocess.run(
['openssl', 'pkcs12', '-in', str(P12_PATH), '-clcerts', '-nokeys',
'-passin', 'env:SEAP_CERT_KEY', '-out', str(CRT_PATH)],
check=True, env={**os.environ, 'SEAP_CERT_KEY': pwd},
stderr=subprocess.DEVNULL,
)
subprocess.run(
['openssl', 'pkcs12', '-in', str(P12_PATH), '-nocerts', '-nodes',
'-passin', 'env:SEAP_CERT_KEY', '-out', str(KEY_PATH)],
check=True, env={**os.environ, 'SEAP_CERT_KEY': pwd},
stderr=subprocess.DEVNULL,
)
CRT_PATH.chmod(0o600)
KEY_PATH.chmod(0o600)
def cleanup_certs():
for p in (CRT_PATH, KEY_PATH):
if p.exists():
try:
subprocess.run(['shred', '-u', str(p)], check=False,
stderr=subprocess.DEVNULL)
except Exception:
p.unlink(missing_ok=True)
atexit.register(cleanup_certs)
# ── SOAP call ──
NS_TEM = 'http://tempuri.org/'
NS_SIC_INTEG = 'http://schemas.datacontract.org/2004/07/SICAP.Service.Integration'
NS_SIC_MODEL = 'http://schemas.datacontract.org/2004/07/SICAP.Supplier.Interface.Model'
# Per-operation namespace overrides (Request type lives in different sub-namespace)
OP_NAMESPACE = {
'SuContracts': NS_SIC_MODEL + '.Contracts',
'SuContractDownload': NS_SIC_MODEL + '.Contracts',
# SuInvoices, SuDirectAcquisitions, Catalog_* stay on base NS_SIC_MODEL
}
ENVELOPE_TPL = '''<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tem="http://tempuri.org/" xmlns:sic="{ns_model}">
<soapenv:Header>
<SeapUserCredentials xmlns="http://tempuri.org" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<Password xmlns="{ns_integ}">{password}</Password>
<Username xmlns="{ns_integ}">{username}</Username>
</SeapUserCredentials>
</soapenv:Header>
<soapenv:Body>
<tem:{op}>
<tem:request>
{fields}
</tem:request>
</tem:{op}>
</soapenv:Body>
</soapenv:Envelope>'''
def build_envelope(op, fields_dict, ns_model=NS_SIC_MODEL):
"""Build SOAP envelope with WCF-required alphabetic field order."""
parts = []
for name in sorted(fields_dict.keys()): # WCF needs alphabetic order
val = fields_dict[name]
if val is None:
parts.append(f' <sic:{name} i:nil="true" xmlns:i="http://www.w3.org/2001/XMLSchema-instance"/>')
else:
parts.append(f' <sic:{name}>{val}</sic:{name}>')
return ENVELOPE_TPL.format(
ns_model=ns_model, ns_integ=NS_SIC_INTEG,
username=os.environ['SEAP_USER'], password=os.environ['SEAP_PASS'],
op=op, fields='\n'.join(parts),
)
def make_session():
s = requests.Session()
s.cert = (str(CRT_PATH), str(KEY_PATH))
return s
def soap_call(session, op, fields, contract='ISupplierWebService', timeout=120):
ns = OP_NAMESPACE.get(op, NS_SIC_MODEL)
body = build_envelope(op, fields, ns_model=ns)
headers = {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': f'"http://tempuri.org/{contract}/{op}"',
}
t0 = time.time()
r = session.post(PROD_URL, data=body.encode(), headers=headers, timeout=timeout)
elapsed = time.time() - t0
return r, elapsed
def parse_response(text):
"""Extract Status, Description, PageIndex, PageTotal from SOAP response."""
status = re.search(r'<Status[^>]*>([^<]+)</Status>', text)
desc = re.search(r'<Description[^>]*>([^<]*)</Description>', text)
pi = re.search(r'<a:PageIndex>(\d+)</a:PageIndex>', text)
pt = re.search(r'<a:PageTotal>(\d+)</a:PageTotal>', text)
fault = re.search(r'<faultcode[^>]*>([^<]+)</faultcode>', text)
return {
'status': status.group(1) if status else (f'FAULT:{fault.group(1)}' if fault else 'UNKNOWN'),
'description': (desc.group(1)[:200] if desc and desc.group(1) else None),
'page_index': int(pi.group(1)) if pi else None,
'page_total': int(pt.group(1)) if pt else None,
'size': len(text),
}
# ── Test definitions ──
def fields_for(op):
"""Return safe field dict for each operation (alphabetic order auto-applied)."""
today = datetime.now().date()
a_week_ago = today - timedelta(days=7)
a_year_ago = today - timedelta(days=365)
s = lambda d: d.strftime('%Y-%m-%dT00:00:00')
e = lambda d: d.strftime('%Y-%m-%dT23:59:59')
# Notice operations: PublicationStartDate / PublicationEndDate + PageIndex
if op in ('SU_PiNotices', 'SU_CNotices', 'SU_CaNotices', 'SU_DCNotices',
'SU_PCNotices', 'SU_RfqNotices', 'SU_ENotices', 'SU_RdcNotices'):
return {
'PageIndex': 1,
'PublicationEndDate': e(today),
'PublicationStartDate': s(a_week_ago),
}
# RFQ Invitations: same
if op == 'SU_RfqInvitations':
return {
'PageIndex': 1,
'PublicationEndDate': e(today),
'PublicationStartDate': s(a_week_ago),
}
# Electronic Auction: separate fields
if op == 'SU_EAProcedure':
return {
'EndDate': e(today),
'PageIndex': 1,
'StartDate': s(a_week_ago),
}
# Beletage-scoped contracts
if op == 'SuContracts':
return {
'ContractEndDate': e(today),
'ContractStartDate': s(a_year_ago),
'PageIndex': 1,
}
if op == 'SuInvoices':
return {
'MaxDate': e(today),
'MinDate': s(a_year_ago),
'PageIndex': 1,
}
if op == 'SuDirectAcquisitions':
return {
'EndDate': e(today),
'PageIndex': 1,
'StartDate': s(a_year_ago),
}
if op == 'Catalog_ListItems':
return {
'LastUpdateEnd': e(today),
'LastUpdateStart': s(a_year_ago),
}
return {'PageIndex': 1}
# ── Tests ──
def t1_supplier_pub_ops(session):
print('\n=== T1: SU_* public-data operations (last 7 days) ===')
print(f'{"Op":<22}{"Status":<18}{"PageTotal":<11}{"Size":<10}{"Time":<8}{"Description"}')
print('-' * 110)
results = {}
for op in SUPPLIER_PUB_OPS:
try:
r, elapsed = soap_call(session, op, fields_for(op))
parsed = parse_response(r.text)
results[op] = parsed
desc = (parsed['description'] or '')[:50]
print(f'{op:<22}{parsed["status"]:<18}{str(parsed["page_total"]):<11}'
f'{parsed["size"]:<10}{elapsed:.1f}s {desc}')
except Exception as ex:
print(f'{op:<22}ERROR - - - {str(ex)[:60]}')
results[op] = {'status': 'ERROR'}
return results
def t2_supplier_own_ops(session):
print('\n=== T2: Su* / Catalog_* Beletage-scoped operations (last year) ===')
print(f'{"Op":<22}{"Status":<18}{"PageTotal":<11}{"Size":<10}{"Time":<8}{"Description"}')
print('-' * 110)
results = {}
for op in SUPPLIER_OWN_OPS:
try:
r, elapsed = soap_call(session, op, fields_for(op))
parsed = parse_response(r.text)
results[op] = parsed
desc = (parsed['description'] or '')[:50]
print(f'{op:<22}{parsed["status"]:<18}{str(parsed["page_total"]):<11}'
f'{parsed["size"]:<10}{elapsed:.1f}s {desc}')
except Exception as ex:
print(f'{op:<22}ERROR - - - {str(ex)[:60]}')
results[op] = {'status': 'ERROR'}
return results
def t3_pagination(session):
"""Probe volume scaling and check PageIndex 0 vs 1."""
print('\n=== T3: Pagination + volume scaling (SU_CaNotices) ===')
# 1 day vs 7 days vs 30 days
today = datetime.now().date()
windows = [('1 day', today - timedelta(days=1)),
('7 days', today - timedelta(days=7)),
('30 days', today - timedelta(days=30))]
for label, start in windows:
fields = {
'PageIndex': 1,
'PublicationEndDate': today.strftime('%Y-%m-%dT23:59:59'),
'PublicationStartDate': start.strftime('%Y-%m-%dT00:00:00'),
}
r, elapsed = soap_call(session, 'SU_CaNotices', fields)
p = parse_response(r.text)
print(f' {label:<10} PageTotal={p["page_total"]:<6} '
f'~{(p["page_total"] or 0) * 100:>8} items '
f'page1 size={p["size"]/1024:.0f}KB {elapsed:.1f}s')
# PageIndex 0 vs 1 — does server treat 0 as "first" or as invalid?
print('\n PageIndex 0 vs 1 vs 999 vs 99999 (last 7 days):')
for pi in [0, 1, 999, 99999]:
fields = {
'PageIndex': pi,
'PublicationEndDate': today.strftime('%Y-%m-%dT23:59:59'),
'PublicationStartDate': (today - timedelta(days=7)).strftime('%Y-%m-%dT00:00:00'),
}
r, _ = soap_call(session, 'SU_CaNotices', fields)
p = parse_response(r.text)
print(f' PageIndex={pi:>5} → status={p["status"]:<14} '
f'returned PageIndex={p["page_index"]} size={p["size"]/1024:.0f}KB')
def t4_rate_limit(session):
"""Sustained call rate to detect throttling."""
print('\n=== T4: Rate limit probe (10 sequential SU_CaNotices, 1-day window) ===')
today = datetime.now().date()
fields = {
'PageIndex': 1,
'PublicationEndDate': today.strftime('%Y-%m-%dT23:59:59'),
'PublicationStartDate': (today - timedelta(days=1)).strftime('%Y-%m-%dT00:00:00'),
}
times = []
statuses = []
for i in range(10):
t0 = time.time()
r, _ = soap_call(session, 'SU_CaNotices', fields)
elapsed = time.time() - t0
p = parse_response(r.text)
times.append(elapsed)
statuses.append((r.status_code, p['status']))
time.sleep(0.1)
print(f' HTTP statuses: {set(statuses)}')
print(f' Avg response: {sum(times)/len(times):.2f}s '
f'min: {min(times):.2f}s max: {max(times):.2f}s')
if all(s[0] == 200 and s[1] == 'Success' for s in statuses):
print(' ✓ No throttling at ~10 req/sec sustained')
else:
print(' ⚠ Some requests were rejected — check statuses')
def t5_su_contracts_beletage(session):
"""Confirm Beletage's own contracts + sample first contract."""
print('\n=== T5: SuContracts — Beletage own contracts (last 5 years) ===')
today = datetime.now().date()
fields = {
'ContractEndDate': today.strftime('%Y-%m-%dT23:59:59'),
'ContractStartDate': (today - timedelta(days=365 * 5)).strftime('%Y-%m-%dT00:00:00'),
'PageIndex': 1,
}
r, elapsed = soap_call(session, 'SuContracts', fields)
p = parse_response(r.text)
print(f' Status: {p["status"]}, PageTotal: {p["page_total"]}, '
f'size: {p["size"]/1024:.0f}KB, {elapsed:.1f}s')
if p['description']:
print(f' Description: {p["description"]}')
# Try to extract first ContractTitle
titles = re.findall(r'<a:ContractTitle[^/]*?>([^<]+)</a:ContractTitle>', r.text)[:5]
nos = re.findall(r'<a:ContractNo[^/]*?>([^<]+)</a:ContractNo>', r.text)[:5]
if nos:
print(' First contracts:')
for no, title in zip(nos, titles + [''] * 5):
print(f' {no}: {title[:60]}')
# ── Main ──
def main():
for var in ('SEAP_USER', 'SEAP_PASS', 'SEAP_CERT_KEY'):
if not os.environ.get(var):
print(f'ERROR: {var} not set — source Infisical first', file=sys.stderr)
sys.exit(1)
print('Setting up cert/key in /dev/shm...')
setup_certs()
print(f' cert: {CRT_PATH} key: {KEY_PATH}')
session = make_session()
t1_results = t1_supplier_pub_ops(session)
t2_results = t2_supplier_own_ops(session)
t3_pagination(session)
t4_rate_limit(session)
t5_su_contracts_beletage(session)
print('\n=== Summary ===')
success_pub = sum(1 for r in t1_results.values() if r.get('status') == 'Success')
success_own = sum(1 for r in t2_results.values() if r.get('status') == 'Success')
print(f' Public-data operations (SU_*): {success_pub}/{len(SUPPLIER_PUB_OPS)} return Success')
print(f' Own-data operations (Su*): {success_own}/{len(SUPPLIER_OWN_OPS)} return Success')
if __name__ == '__main__':
main()