forked from mtusi2026/mtusi2026lab2
Подготовить skeleton лабораторной 2 по мессенджеру
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Публичные проверки
|
||||
|
||||
Python **3.9+**, стандартная библиотека. Сервер уже должен быть запущен; проверки не создают готовое приложение и сами не управляют Docker. `make check` можно выполнить на исходном skeleton, `make test` — только после реализации API. Полная OpenAPI-валидация отдельно: `make validate` после установки `requirements-dev.txt` в venv.
|
||||
|
||||
```sh
|
||||
make check
|
||||
make test BASE_URL=http://localhost:8080
|
||||
# без make:
|
||||
python3 tests/smoke.py --base-url http://localhost:8080
|
||||
```
|
||||
|
||||
`smoke.py` выполняет **один сквозной сценарий**, автоматически выбирая профиль из `lab.json`: health → три пользователя → вход с lab2 → чат → сообщения → пагинация → ошибки доступа → идемпотентность с lab3 → файл с lab4 → thumbnail с lab5 → logout. Он проверяет JSON по используемому подмножеству схемы, коды/headers, Unicode, SHA-256 оригинала и фактические JPEG-размеры thumbnail. Это публичный минимум, не полный fuzz/security/load suite.
|
||||
|
||||
Каждый запуск создаёт уникальных синтетических пользователей, чат и сообщения. Данные автоматически не удаляются: в API курса нет delete. Для чистого повтора используйте отдельный тестовый стенд/volume и осознанный сброс своего окружения. Session tokens хранятся только в памяти процесса и не печатаются/не сохраняются.
|
||||
|
||||
## Проверка сохранности, lab2+
|
||||
|
||||
Скопируйте `scripts/restart.example.sh` в `scripts/restart.sh`, реализуйте перезапуск **API и всех используемых хранилищ** с сохранением volumes и задайте executable bit. Скрипт может опираться на ваш compose.yaml или stack.yaml. Он не должен удалять volumes, повторно seed-ить БД или менять тестовые данные.
|
||||
|
||||
```sh
|
||||
chmod +x scripts/restart.sh
|
||||
make test-persistence ACTION_SCRIPT=scripts/restart.sh
|
||||
```
|
||||
|
||||
Тест создаёт данные и сессию, запускает указанный файл без shell interpolation, ожидает готовности и проверяет те же данные **с прежним токеном**. В lab4+ также проверяется исходный файл. Hook может работать до 90 секунд, готовность затем ожидается до 90 секунд. TTL сессии для этого теста — минимум 300 секунд. Успех не доказывает, что hook действительно перезапустил БД: приложите команды/состояния контейнеров до и после.
|
||||
|
||||
## Проверка отказа, lab3+
|
||||
|
||||
Реализуйте `scripts/stop-one.sh` на основе примера. `TARGET_INSTANCE_ID` в окружении содержит alias обслужившей тест API-реплики: сопоставьте его своему контейнеру/Swarm task и остановите именно его. Скрипт должен быстро вернуть 0 и **оставить эту реплику остановленной**; восстановление выполняйте отдельно. Swarm может создать новую task с другим ID — это допустимо.
|
||||
|
||||
```sh
|
||||
chmod +x scripts/stop-one.sh
|
||||
make test-failover ACTION_SCRIPT=scripts/stop-one.sh
|
||||
```
|
||||
|
||||
Перед отказом тест наблюдает ≥2 API-реплики; 15 секунд отправляет сообщения параллельно выполнению hook, повторяет неопределённый POST с тем же ключом, проверяет восстановление записи за ≤10 секунд, отсутствие остановленной реплики в последних ответах, сохранность подтверждённых сообщений и отсутствие дублей. Ошибки переходного периода учитываются. Затем восстановите реплику и повторите для другой. Скрипт — проверка одного отказа API, не гарантия HA БД/LB/host. Вывод hook скрыт, чтобы не утекли секреты; отлаживайте его отдельно с безопасным выводом.
|
||||
|
||||
## Нагрузка
|
||||
|
||||
```sh
|
||||
python3 tests/load.py --duration 30 --concurrency 4 --output evidence/load.json
|
||||
```
|
||||
|
||||
Это простой **closed-loop** генератор POST сообщений; он ограничен производительностью клиента и страдает coordinated omission. Он выдаёт successful RPS, ошибки и p50/p95/p99 **только успешных запросов**. Во время нагрузки нет прозрачных retry; timeout мог скрыть совершённую запись, поэтому это throughput HTTP-подтверждений, не точный счётчик COMMIT. Изменение размера истории входит в профиль. Для серьёзного исследования используйте k6/Locust/wrk либо свой обоснованный генератор, а не выводите максимальную пропускную способность из одного запуска этого скрипта. Для нагрузки pipeline изображений нужен отдельный сценарий студента.
|
||||
|
||||
## HTTPS, lab7
|
||||
|
||||
```sh
|
||||
make test BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt
|
||||
make test-security BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt
|
||||
```
|
||||
|
||||
Все скрипты принимают `--ca-file`/`CA_FILE` и проверяют серверный сертификат и hostname. `--insecure` отсутствует. TLS-тест сначала проверяет рабочее доверенное соединение, затем намеренно пустой trust store и именно ошибку проверки сертификата; network timeout не засчитывается как правильный отказ. **Внутреннее mTLS, identity/authorization, OpenBao policy и hardening проверяют отдельные тесты студента** по trust matrix. Клиентский сертификат внешнему учебному REST-клиенту не требуется: mTLS находится на внутренних связях.
|
||||
|
||||
## Что ещё остаётся доказать
|
||||
|
||||
Smoke не доказывает persistence, число реальных контейнеров, durability брокера, outbox, отсутствие утечек/уязвимостей, полноту telemetry или выполнение уровня 4/5. Конкурентные/нагрузочные/негативные проверки своего решения добавляйте отдельно. Список защиты текущей лабы находится в README.md. Проверки рассчитаны на localhost-стенд, не на production.
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
"""HTTP helpers for public black-box tests; Python 3.9+, standard library only."""
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LAB = json.loads((ROOT / 'lab.json').read_text())['number']
|
||||
SPEC = json.loads((ROOT / 'contracts/openapi.json').read_text())
|
||||
|
||||
class Failure(AssertionError):
|
||||
pass
|
||||
|
||||
def check(condition, message):
|
||||
if not condition:
|
||||
raise Failure(message)
|
||||
|
||||
def timestamp(value):
|
||||
check(isinstance(value, str) and value.endswith('Z'), 'timestamp must be UTC ending in Z')
|
||||
try:
|
||||
return dt.datetime.fromisoformat(value[:-1] + '+00:00')
|
||||
except ValueError as exc:
|
||||
raise Failure('invalid RFC3339 timestamp') from exc
|
||||
|
||||
def validate(value, schema, where='response'):
|
||||
"""Validate the JSON Schema subset used by the supplied contract, not arbitrary OpenAPI."""
|
||||
if '$ref' in schema:
|
||||
target = SPEC
|
||||
for part in schema['$ref'].split('/')[1:]:
|
||||
target = target[part]
|
||||
return validate(value, target, where)
|
||||
kinds = schema.get('type', [])
|
||||
if isinstance(kinds, str):
|
||||
kinds = [kinds]
|
||||
matches = {'null':value is None, 'boolean':isinstance(value,bool), 'integer':isinstance(value,int) and not isinstance(value,bool), 'number':isinstance(value,(int,float)) and not isinstance(value,bool), 'string':isinstance(value,str), 'array':isinstance(value,list), 'object':isinstance(value,dict)}
|
||||
if kinds:
|
||||
check(any(matches.get(k,False) for k in kinds), where + ': incorrect type')
|
||||
if 'enum' in schema:
|
||||
check(value in schema['enum'], where + ': invalid enum')
|
||||
if 'const' in schema:
|
||||
check(value == schema['const'], where + ': invalid const')
|
||||
if isinstance(value,dict):
|
||||
props = schema.get('properties',{})
|
||||
check(all(k in value for k in schema.get('required',[])), where + ': missing required fields')
|
||||
if schema.get('additionalProperties') is False:
|
||||
check(not set(value).difference(props), where + ': unexpected fields')
|
||||
for key, sub in props.items():
|
||||
if key in value:
|
||||
validate(value[key], sub, where + '.' + key)
|
||||
if isinstance(value,list):
|
||||
check(len(value) >= schema.get('minItems',0), where + ': too few items')
|
||||
check(len(value) <= schema.get('maxItems',float('inf')), where + ': too many items')
|
||||
if schema.get('uniqueItems'):
|
||||
encoded = [json.dumps(x,sort_keys=True) for x in value]
|
||||
check(len(encoded)==len(set(encoded)), where + ': duplicate items')
|
||||
if 'items' in schema:
|
||||
for item in value:
|
||||
validate(item, schema['items'], where + '[]')
|
||||
if isinstance(value,str):
|
||||
check(len(value) >= schema.get('minLength',0), where + ': too short')
|
||||
check(len(value) <= schema.get('maxLength',float('inf')), where + ': too long')
|
||||
if 'pattern' in schema:
|
||||
check(re.search(schema['pattern'], value) is not None, where + ': pattern mismatch')
|
||||
if schema.get('format') == 'uuid':
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
except ValueError as exc:
|
||||
raise Failure(where + ': invalid UUID') from exc
|
||||
if schema.get('format') == 'date-time':
|
||||
timestamp(value)
|
||||
if isinstance(value,(int,float)) and not isinstance(value,bool):
|
||||
check(value >= schema.get('minimum',-float('inf')), where + ': below minimum')
|
||||
check(value <= schema.get('maximum',float('inf')), where + ': above maximum')
|
||||
if 'anyOf' in schema:
|
||||
for alternative in schema['anyOf']:
|
||||
try:
|
||||
validate(value, alternative, where)
|
||||
break
|
||||
except Failure:
|
||||
pass
|
||||
else:
|
||||
raise Failure(where + ': no anyOf branch matches')
|
||||
|
||||
def shape(value, name):
|
||||
validate(value, SPEC['components']['schemas'][name])
|
||||
return value
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
class Client:
|
||||
def __init__(self, base_url=None, ca_file=None, timeout=10):
|
||||
self.base = (base_url or os.environ.get('BASE_URL') or ('https://localhost:8443' if LAB==7 else 'http://localhost:8080')).rstrip('/')
|
||||
check(self.base.startswith(('http://','https://')), 'BASE_URL must be HTTP(S)')
|
||||
check(LAB != 7 or self.base.startswith('https://'), 'lab7 requires HTTPS')
|
||||
self.timeout = timeout
|
||||
context = ssl.create_default_context(cafile=ca_file or os.environ.get('CA_FILE') or None)
|
||||
self.opener = urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=context))
|
||||
|
||||
def call(self, method, path, expected=200, body=None, actor=None, headers=None, raw=None, schema=None):
|
||||
h = {'Accept':'application/json'}
|
||||
if actor:
|
||||
h['X-User-Id' if LAB==1 else 'Authorization'] = actor['id'] if LAB==1 else 'Bearer ' + actor['token']
|
||||
if body is not None:
|
||||
raw = json.dumps(body,ensure_ascii=False).encode('utf-8')
|
||||
h['Content-Type'] = 'application/json'
|
||||
h.update(headers or {})
|
||||
req = urllib.request.Request(self.base + path, data=raw, headers=h, method=method)
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = self.opener.open(req, timeout=self.timeout)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
except (urllib.error.URLError,TimeoutError,OSError) as exc:
|
||||
# Do not print URL query, headers, request body, or credentials.
|
||||
raise Failure(method + ' ' + path.split('?')[0] + ': connection/TLS failure (' + type(exc).__name__ + ')') from None
|
||||
with response:
|
||||
status = response.code
|
||||
rh = response.headers
|
||||
data = response.read(16*1024*1024 + 1)
|
||||
check(len(data) <= 16*1024*1024, 'response exceeds test safety limit')
|
||||
allowed = [expected] if isinstance(expected,int) else expected
|
||||
check(status in allowed, method + ' ' + path.split('?')[0] + ': expected ' + str(allowed) + ', received ' + str(status))
|
||||
check(bool(rh.get('X-Request-Id')), 'missing X-Request-Id')
|
||||
if LAB>=3:
|
||||
check(bool(rh.get('X-Instance-Id')), 'missing X-Instance-Id')
|
||||
payload = None
|
||||
if schema or status>=400:
|
||||
check(rh.get_content_type()=='application/json', 'expected application/json')
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except (ValueError,UnicodeError):
|
||||
raise Failure('invalid response JSON') from None
|
||||
shape(payload, 'Error' if status>=400 else schema)
|
||||
if status>=400:
|
||||
check(payload['error']['request_id']==rh.get('X-Request-Id'),'error request_id differs from header')
|
||||
if status==401 and LAB>=2:
|
||||
scheme = 'Basic' if path=='/api/v1/auth/sessions' else 'Bearer'
|
||||
check(rh.get('WWW-Authenticate','').lower().startswith(scheme.lower()), 'incorrect WWW-Authenticate challenge')
|
||||
if status==204:
|
||||
check(not data, '204 must not contain body')
|
||||
return {'status':status,'headers':rh,'json':payload,'bytes':data,'elapsed':time.monotonic()-start}
|
||||
|
||||
def user(self, label):
|
||||
username = 'u_' + label + '_' + uuid.uuid4().hex[:12]
|
||||
password = 'T3st-' + uuid.uuid4().hex
|
||||
body = {'username':username,'display_name':'Студент ' + label}
|
||||
if LAB>=2:
|
||||
body['password'] = password
|
||||
user = self.call('POST','/api/v1/users',201,body=body,schema='User')['json']
|
||||
check(user['username']==username,'username changed')
|
||||
actor = {'id':user['id'],'user':user,'registration':body}
|
||||
if LAB>=2:
|
||||
credential = base64.b64encode((username+':'+password).encode()).decode()
|
||||
session = self.call('POST','/api/v1/auth/sessions',201,headers={'Authorization':'Basic '+credential},schema='Session')['json']
|
||||
check(session['user']==user,'session user mismatch')
|
||||
check(timestamp(session['expires_at']) > dt.datetime.now(dt.timezone.utc),'session already expired')
|
||||
actor['token'] = session['token']
|
||||
return actor
|
||||
|
||||
def bootstrap(self):
|
||||
self.call('GET','/health/live',schema='Health')
|
||||
self.call('GET','/health/ready',schema='Health')
|
||||
alice, bob, eve = (self.user(label) for label in ('alice','bob','eve'))
|
||||
chat = self.call('POST','/api/v1/chats',201,actor=alice,body={'title':'Контрактный тест','member_ids':[alice['id'],bob['id']]},schema='Chat')['json']
|
||||
check(set(chat['member_ids'])=={alice['id'],bob['id']},'chat member mismatch')
|
||||
return alice,bob,eve,chat
|
||||
|
||||
def message(self, actor, chat, text, key=None, attachments=None):
|
||||
body = {'text':text}
|
||||
if attachments is not None:
|
||||
body['attachment_ids'] = attachments
|
||||
headers = {'Idempotency-Key':key or uuid.uuid4().hex} if LAB>=3 else {}
|
||||
return self.call('POST','/api/v1/chats/'+chat['id']+'/messages',201,actor=actor,body=body,headers=headers,schema='Message')
|
||||
|
||||
def attachment(self, actor, chat):
|
||||
content = (ROOT / 'tests/fixtures/sample.png').read_bytes()
|
||||
att = self.call('POST','/api/v1/chats/'+chat['id']+'/attachments?filename=sample.png',201 if LAB==4 else 202,actor=actor,raw=content,headers={'Content-Type':'image/png'},schema='Attachment')['json']
|
||||
check(att['status']==('ready' if LAB==4 else 'queued'),'incorrect upload status')
|
||||
check(att['chat_id']==chat['id'] and att['owner_id']==actor['id'],'attachment ownership mismatch')
|
||||
check(att['sha256']==hashlib.sha256(content).hexdigest() and att['size_bytes']==len(content),'original checksum/size mismatch')
|
||||
return att,content
|
||||
|
||||
def wait_ready(self, actor, att, wait_seconds=60):
|
||||
deadline = time.monotonic()+wait_seconds
|
||||
while time.monotonic()<deadline:
|
||||
att = self.call('GET','/api/v1/attachments/'+att['id'],actor=actor,schema='Attachment')['json']
|
||||
check(att['status']!='failed','attachment reached failed')
|
||||
if att['status']=='ready':
|
||||
return att
|
||||
time.sleep(0.3)
|
||||
raise Failure('attachment did not become ready within test deadline')
|
||||
|
||||
def arguments(description, configure=None):
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description=description)
|
||||
p.add_argument('--base-url', default=None)
|
||||
p.add_argument('--ca-file', default=None)
|
||||
if configure:
|
||||
configure(p)
|
||||
return p.parse_args()
|
||||
|
||||
def run(main):
|
||||
try:
|
||||
main()
|
||||
except (Failure,ValueError,OSError) as exc:
|
||||
print('FAIL:', str(exc))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small closed-loop HTTP write benchmark; not an open-loop capacity proof."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from client import Client, Failure, LAB, arguments, check, run
|
||||
|
||||
|
||||
def main():
|
||||
def configure(p):
|
||||
p.add_argument('--duration',type=float,default=30)
|
||||
p.add_argument('--concurrency',type=int,default=4)
|
||||
p.add_argument('--output',default=None,help='Optional JSON metrics, no session tokens')
|
||||
args = arguments('Synthetic POST message load, closed loop; creates users/messages',configure)
|
||||
check(1<=args.duration<=3600,'duration must be 1..3600 seconds')
|
||||
check(1<=args.concurrency<=128,'concurrency must be 1..128')
|
||||
c = Client(args.base_url,args.ca_file)
|
||||
alice,bob,eve,chat = c.bootstrap()
|
||||
barrier = threading.Barrier(args.concurrency)
|
||||
def worker(worker_id):
|
||||
local = Client(args.base_url,args.ca_file,timeout=5)
|
||||
barrier.wait()
|
||||
deadline = time.monotonic()+args.duration
|
||||
latencies,errors = [],0
|
||||
index = 0
|
||||
while time.monotonic()<deadline:
|
||||
start = time.monotonic()
|
||||
try:
|
||||
local.message(alice,chat,'load-%s-%s' % (worker_id,index))
|
||||
latencies.append(time.monotonic()-start)
|
||||
except Failure:
|
||||
errors += 1
|
||||
index += 1
|
||||
return latencies,errors
|
||||
started = time.monotonic()
|
||||
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
|
||||
results = list(pool.map(worker,range(args.concurrency)))
|
||||
elapsed = time.monotonic()-started
|
||||
latencies = sorted(x for values,_ in results for x in values)
|
||||
errors = sum(n for _,n in results)
|
||||
total = len(latencies)+errors
|
||||
def percentile(p):
|
||||
return round(1000*latencies[max(0,math.ceil(p*len(latencies))-1)],3) if latencies else None
|
||||
report = {'lab':LAB,'workload':'closed-loop POST messages; no automatic retries','requested_duration_seconds':args.duration,'elapsed_seconds':round(elapsed,3),'concurrency':args.concurrency,'successful_requests':len(latencies),'failed_requests':errors,'error_ratio':round(errors/total,6) if total else None,'successful_rps':round(len(latencies)/elapsed,3),'successful_latency_ms':{'p50':percentile(.50),'p95':percentile(.95),'p99':percentile(.99)},'note':'Latency percentiles cover successes only. Timeouts/errors are counted separately; closed-loop has coordinated omission and client overhead.'}
|
||||
text = json.dumps(report,ensure_ascii=False,indent=2)
|
||||
if args.output:
|
||||
dest = Path(args.output)
|
||||
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||
dest.write_text(text+'\n')
|
||||
print(text)
|
||||
check(bool(latencies),'no successful writes')
|
||||
# Errors are reported, not treated as a universal load-test threshold.
|
||||
|
||||
if __name__=='__main__':
|
||||
run(main)
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit operator-authored hook; tokens remain in memory, volumes are never removed by this test."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
from client import Client, Failure, LAB, arguments, check, run
|
||||
|
||||
|
||||
def history(c, actor, chat):
|
||||
path = '/api/v1/chats/'+chat['id']+'/messages?limit=100'
|
||||
result = []
|
||||
seen = set()
|
||||
for _ in range(1000):
|
||||
page = c.call('GET',path,actor=actor,schema='MessagePage')['json']
|
||||
result.extend(page['items'])
|
||||
cursor = page['next_cursor']
|
||||
if cursor is None:
|
||||
return result
|
||||
check(cursor not in seen,'cursor cycle')
|
||||
seen.add(cursor)
|
||||
path = '/api/v1/chats/'+chat['id']+'/messages?limit=100&cursor='+quote(cursor,safe='')
|
||||
raise Failure('history pagination exceeded safety bound')
|
||||
|
||||
|
||||
def main():
|
||||
def configure(p):
|
||||
p.add_argument('--mode', choices=['persistence','failover'], required=True)
|
||||
p.add_argument('--action-script',required=True,help='Executable script authored by student; restarts stack or stops ONE API replica')
|
||||
args = arguments('Explicit restart/failover scenario; modifies only synthetic API data',configure)
|
||||
check(LAB>=2,'persistence requires lab2+')
|
||||
check(args.mode!='failover' or LAB>=3,'failover requires lab3+')
|
||||
script = Path(args.action_script).expanduser().resolve()
|
||||
check(script.is_file() and os.access(script,os.X_OK),'action script must exist and be executable (chmod +x)')
|
||||
check('.example.' not in script.name,'copy and implement the example hook first')
|
||||
c = Client(args.base_url,args.ca_file,timeout=2)
|
||||
alice,bob,eve,chat = c.bootstrap()
|
||||
sent = c.message(alice,chat,'Сохранить при отказе')
|
||||
original = sent['json']
|
||||
target = sent['headers'].get('X-Instance-Id','')
|
||||
attachment = content = None
|
||||
if LAB>=4:
|
||||
attachment,content = c.attachment(alice,chat)
|
||||
if LAB>=5:
|
||||
attachment = c.wait_ready(alice,attachment)
|
||||
env = dict(os.environ)
|
||||
env['TARGET_INSTANCE_ID'] = target
|
||||
observed = set()
|
||||
if args.mode=='failover':
|
||||
for _ in range(40):
|
||||
observed.add(c.call('GET','/api/v1/users/me',actor=alice,schema='User')['headers']['X-Instance-Id'])
|
||||
check(len(observed)>=2,'less than two API instances observed before failure; verify LB/session affinity')
|
||||
print('Executing explicit '+args.mode+' hook; hook output suppressed to avoid leaking secrets.')
|
||||
# No shell interpolation and no Docker commands in this test.
|
||||
proc = subprocess.Popen([str(script)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
started = time.monotonic()
|
||||
successes,failures,after_instances = [],0,[]
|
||||
try:
|
||||
if args.mode=='persistence':
|
||||
try:
|
||||
code = proc.wait(timeout=90)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure('restart hook exceeded 90 seconds') from None
|
||||
check(code==0,'restart hook failed (inspect your script locally)')
|
||||
deadline = time.monotonic()+90
|
||||
while True:
|
||||
try:
|
||||
c.call('GET','/health/ready',schema='Health')
|
||||
c.call('GET','/api/v1/users/me',actor=alice,schema='User')
|
||||
break
|
||||
except Failure:
|
||||
check(time.monotonic()<deadline,'stack/session did not recover within 90 seconds')
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
first_recovery = None
|
||||
key = uuid.uuid4().hex
|
||||
text = 'Запись во время отказа ' + key
|
||||
while time.monotonic()-started < 15:
|
||||
code = proc.poll()
|
||||
check(code is None or code==0,'stop-one hook failed (inspect your script locally)')
|
||||
check(code is not None or time.monotonic()-started<10,'stop-one hook must return within 10 seconds')
|
||||
try:
|
||||
response = c.message(alice,chat,text,key=key)
|
||||
msg = response['json']
|
||||
if code==0:
|
||||
if first_recovery is None:
|
||||
first_recovery = time.monotonic()-started
|
||||
after_instances.append(response['headers']['X-Instance-Id'])
|
||||
successes.append(msg)
|
||||
key = uuid.uuid4().hex
|
||||
text = 'Запись во время отказа ' + key
|
||||
except Failure:
|
||||
failures += 1
|
||||
# Keep SAME key/body after an uncertain POST result.
|
||||
time.sleep(0.1)
|
||||
check(proc.poll()==0,'stop-one hook did not finish successfully')
|
||||
check(first_recovery is not None and first_recovery<=10,'writes did not recover within 10 seconds from hook start')
|
||||
check(len(after_instances)>=5,'too few successful writes after stop')
|
||||
check(target not in after_instances[-5:],'target replica still serves requests; hook must leave it stopped')
|
||||
print(json.dumps({'observed_instances_before':len(observed),'successful_writes':len(successes),'failed_attempts':failures,'first_recovery_seconds':round(first_recovery,3)},ensure_ascii=False))
|
||||
# Existing token, no re-login. Read all confirmations and reject duplicates.
|
||||
messages = history(c,bob,chat)
|
||||
ids = [m['id'] for m in messages]
|
||||
check(len(ids)==len(set(ids)),'duplicated message IDs in history')
|
||||
check(any(m==original for m in messages),'confirmed pre-failure message lost or altered')
|
||||
for msg in successes:
|
||||
check(any(m==msg for m in messages),'confirmed in-flight message lost or altered')
|
||||
# Bodies are unique in this scenario: an uncertain request may exist once, never twice.
|
||||
texts = [m['text'] for m in messages]
|
||||
check(len(texts)==len(set(texts)),'uncertain retry created duplicate message')
|
||||
if attachment:
|
||||
c.call('GET','/api/v1/attachments/'+attachment['id'],actor=bob,schema='Attachment')
|
||||
downloaded = c.call('GET','/api/v1/attachments/'+attachment['id']+'/content',actor=bob)
|
||||
check(downloaded['bytes']==content,'attachment lost or changed across failure')
|
||||
print('PASS: '+args.mode+' preserved confirmed data and existing session. Hook scope needs operator evidence.')
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
if __name__=='__main__':
|
||||
run(main)
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One complete public acceptance scenario, cumulative by lab number."""
|
||||
import base64
|
||||
import hashlib
|
||||
import uuid
|
||||
from client import Client, LAB, arguments, check, run, timestamp
|
||||
|
||||
|
||||
def main():
|
||||
args = arguments('Messenger public smoke; creates synthetic users and messages')
|
||||
c = Client(args.base_url,args.ca_file)
|
||||
alice,bob,eve,chat = c.bootstrap()
|
||||
path = '/api/v1/chats/'+chat['id']
|
||||
c.call('POST','/api/v1/users',409,body=alice['registration'])
|
||||
c.call('GET','/api/v1/users/me',401)
|
||||
c.call('GET',path,404,actor=eve)
|
||||
me = c.call('GET','/api/v1/users/me',actor=alice,schema='User')['json']
|
||||
check(me==alice['user'],'current user mismatch')
|
||||
listed = c.call('GET','/api/v1/chats?limit=100',actor=bob,schema='ChatPage')['json']
|
||||
check(any(x['id']==chat['id'] for x in listed['items']),'member cannot list chat')
|
||||
hidden = c.call('GET','/api/v1/chats?limit=100',actor=eve,schema='ChatPage')['json']
|
||||
check(all(x['id']!=chat['id'] for x in hidden['items']),'chat leaks to outsider')
|
||||
key = uuid.uuid4().hex
|
||||
first = c.message(alice,chat,'Привет, мир 👋',key=key)['json']
|
||||
second = c.message(bob,chat,'Сообщение Боба')['json']
|
||||
check(first['sender_id']==alice['id'] and second['sender_id']==bob['id'],'sender was not derived from actor')
|
||||
check(first['chat_id']==second['chat_id']==chat['id'],'wrong chat in message')
|
||||
check(first['text']=='Привет, мир 👋' and second['text']=='Сообщение Боба','message text changed')
|
||||
page1 = c.call('GET',path+'/messages?limit=1',actor=bob,schema='MessagePage')['json']
|
||||
check(len(page1['items'])==1 and page1['next_cursor'],'first page must have one item and cursor')
|
||||
from urllib.parse import quote
|
||||
page2 = c.call('GET',path+'/messages?limit=1&cursor='+quote(page1['next_cursor'],safe=''),actor=alice,schema='MessagePage')['json']
|
||||
check(len(page2['items'])==1 and page2['next_cursor'] is None,'second page must finish history')
|
||||
ordered = sorted([first,second],key=lambda x:(timestamp(x['created_at']),uuid.UUID(x['id']).int))
|
||||
check(page1['items']+page2['items']==ordered,'pagination lost, duplicated or reordered a message')
|
||||
h = {'Idempotency-Key':uuid.uuid4().hex} if LAB>=3 else {}
|
||||
c.call('POST',path+'/messages',400,actor=alice,body={'text':''},headers=h)
|
||||
c.call('POST',path+'/messages',404,actor=eve,body={'text':'чужое'},headers=h)
|
||||
c.call('GET',path+'/messages?limit=0',400,actor=alice)
|
||||
if LAB>=2:
|
||||
bad = base64.b64encode((alice['registration']['username']+':wrong-password').encode()).decode()
|
||||
c.call('POST','/api/v1/auth/sessions',401,headers={'Authorization':'Basic '+bad})
|
||||
good = base64.b64encode((alice['registration']['username']+':'+alice['registration']['password']).encode()).decode()
|
||||
c.call('GET','/api/v1/users/me',401,headers={'Authorization':'Basic '+good})
|
||||
c.call('GET','/api/v1/users/me',401,headers={'X-User-Id':alice['id']})
|
||||
me = c.call('GET','/api/v1/users/me',actor=alice,headers={'X-User-Id':eve['id']},schema='User')['json']
|
||||
check(me['id']==alice['id'],'X-User-Id overrode bearer identity')
|
||||
if LAB>=3:
|
||||
replay = c.message(alice,chat,first['text'],key=key)['json']
|
||||
check(replay==first,'idempotency replay differs')
|
||||
c.call('POST',path+'/messages',409,actor=alice,body={'text':'другой body'},headers={'Idempotency-Key':key})
|
||||
c.call('POST',path+'/messages',400,actor=alice,body={'text':'без ключа'})
|
||||
if LAB>=4:
|
||||
att,content = c.attachment(alice,chat)
|
||||
# A queued attachment must already be linkable to its own chat.
|
||||
attached = c.message(alice,chat,'',attachments=[att['id']])['json']
|
||||
check(attached['attachment_ids']==[att['id']],'message lost attachment')
|
||||
ready = c.wait_ready(bob,att) if LAB>=5 else att
|
||||
check(ready['error_code'] is None,'ready attachment has an error')
|
||||
download = c.call('GET','/api/v1/attachments/'+att['id']+'/content',actor=bob)
|
||||
check(download['bytes']==content,'download differs from original')
|
||||
check(download['headers'].get_content_type()=='image/png','original content type mismatch')
|
||||
for suffix in ('','/content'):
|
||||
c.call('GET','/api/v1/attachments/'+att['id']+suffix,404,actor=eve)
|
||||
if LAB>=5:
|
||||
variants = [x for x in ready['variants'] if x['name']=='thumbnail']
|
||||
check(len(variants)==1,'expected exactly one thumbnail')
|
||||
v = variants[0]
|
||||
check((v['width'],v['height'])==(256,160),'thumbnail metadata has wrong dimensions')
|
||||
thumb = c.call('GET','/api/v1/attachments/'+att['id']+'/content?variant=thumbnail',actor=bob)
|
||||
check(thumb['headers'].get_content_type()=='image/jpeg','thumbnail content type mismatch')
|
||||
check(len(thumb['bytes'])==v['size_bytes'] and hashlib.sha256(thumb['bytes']).hexdigest()==v['sha256'],'thumbnail checksum mismatch')
|
||||
from image_probe import jpeg_size
|
||||
check(jpeg_size(thumb['bytes'])==(256,160),'actual JPEG dimensions mismatch')
|
||||
if LAB>=2:
|
||||
c.call('DELETE','/api/v1/auth/sessions/current',204,actor=alice)
|
||||
c.call('GET','/api/v1/users/me',401,actor=alice)
|
||||
c.call('DELETE','/api/v1/auth/sessions/current',401,actor=alice)
|
||||
print('PASS: lab%d public API scenario; infrastructure and grading evidence remain separate.' % LAB)
|
||||
|
||||
if __name__=='__main__':
|
||||
run(main)
|
||||
Reference in New Issue
Block a user