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)
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user