Подготовить skeleton лабораторной 2 по мессенджеру

This commit is contained in:
Mikhail Verkovykh
2026-09-10 18:01:35 +03:00
parent 4c14ccbadd
commit 65003b6119
30 changed files with 3247 additions and 98 deletions
+57
View File
@@ -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
View File
@@ -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)
+58
View File
@@ -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)
+129
View File
@@ -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)
+82
View File
@@ -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)