""" test_guards.py — тесты приёмки механизмов цитирования. ПУБЛИКУЮТСЯ вместе с guards.py (п. 7.10 ТУ). Их назначение — дать возможность проверить исполнение раздела 7 Условий, не читая весь код сервиса. Проверяют не «код написан», а «ограничение работает»: везде, где можно, утверждение делается о фактическом результате, а не о наличии строчки. Запуск: python3 test_guards.py Код возврата 0 — все прошли. Ненулевой — запускать сервис нельзя. """ import sqlite3 import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import config import guards FAILED = [] PASSED = 0 def check(name, condition, detail=''): global PASSED if condition: PASSED += 1 print(f' OK {name}') else: FAILED.append(f'{name}: {detail}') print(f' FAIL {name} {detail}') def fresh_db(): con = sqlite3.connect(':memory:') con.executescript((Path(__file__).resolve().parent / 'schema.sql').read_text(encoding='utf-8')) return con SRC_OK = { 'doc_id': 'art:1', 'author': 'Афанасьев А. Н.', 'title': 'Народные русские сказки', 'year': '1873', 'journal': None, 'doi_or_url': 'https://example.org/1', 'holding_institution': None, 'source_type': 'collection', 'text_extraction_method': 'native', 'blocked': 0, } SRC_OCR = {**SRC_OK, 'doc_id': 'art:2', 'text_extraction_method': 'ocr'} SRC_NOAUTH = {**SRC_OK, 'doc_id': 'art:3', 'author': ''} SRC_BLOCKED = {**SRC_OK, 'doc_id': 'art:4', 'blocked': 1} # ============================================================ print('\nG1 — обрезка Фрагмента (п. 7.2 ТУ)') # ============================================================ long_text = ('Первое предложение здесь. ' * 200) out = guards.trim_fragment(long_text) check('не длиннее лимита', len(out) <= config.FRAGMENT_MAX_CHARS, f'получено {len(out)} при лимите {config.FRAGMENT_MAX_CHARS}') check('обрезано по границе предложения', out.rstrip().endswith('.'), f'хвост: ...{out[-30:]!r}') no_punct = 'а' * 3000 out2 = guards.trim_fragment(no_punct) check('текст без пунктуации всё равно обрезан', len(out2) <= config.FRAGMENT_MAX_CHARS + 1, f'получено {len(out2)}') check('обрезка без границы помечена многоточием', out2.endswith('…')) short = 'Короткий текст.' check('короткий текст не тронут', guards.trim_fragment(short) == short) # ============================================================ print('\nG2 — атрибуция и пометка OCR (п. 7.3 ТУ)') # ============================================================ check('источник с автором и названием проходит', guards.has_attribution(SRC_OK)) check('источник без автора не проходит', not guards.has_attribution(SRC_NOAUTH)) check('источник без названия не проходит', not guards.has_attribution({**SRC_OK, 'title': ' '})) _NOLOC = {**SRC_OK, 'doi_or_url': None, 'holding_institution': None, 'imprint': None, 'journal': None} check('источник без единого локатора не проходит', not guards.has_attribution(_NOLOC)) check('локатором годится место хранения', guards.has_attribution({**_NOLOC, 'holding_institution': 'Internet Archive (archive.org)'})) check('локатором годятся выходные данные', guards.has_attribution({**_NOLOC, 'imprint': 'Гродно : Губ. тип., 1868'})) check('организация вместо автора проходит (периодика)', guards.has_attribution({**SRC_OK, 'author': 'Отделение этнографии ИРГО'})) check('год не обязателен', guards.has_attribution({**SRC_OK, 'year': None})) check('пустой источник не проходит', not guards.has_attribution(None)) frag = 'Некоторый текст фрагмента.' check('пометка OCR добавлена', guards.OCR_NOTICE in guards.apply_ocr_notice(frag, SRC_OCR)) check('пометка не добавлена к обычному тексту', guards.OCR_NOTICE not in guards.apply_ocr_notice(frag, SRC_OK)) # Пометка должна быть ВНУТРИ поля fragment, а не отдельным полем: # отдельное поле клиент может не показать. item = guards.build_response_item(frag, SRC_OCR, 'art:2', 30) check('пометка внутри поля fragment, а не отдельным полем', guards.OCR_NOTICE in item.get('fragment', ''), f'ключи ответа: {sorted(item)}') # Отключить пометку нечем: у функций сборки нет соответствующего параметра. import inspect sig = str(inspect.signature(guards.build_response_item)) + str(inspect.signature(guards.apply_ocr_notice)) check('нет параметра для отключения пометки OCR', not any(w in sig.lower() for w in ('ocr=', 'no_ocr', 'skip_ocr', 'raw=', 'plain=')), sig) # ============================================================ print('\nG3 — бюджет Источника (п. 7.5 ТУ)') # ============================================================ db = fresh_db() TOK, EPOCH = 'tok-test', 1 v = guards.check_result(db, TOK, EPOCH, 'art:1', SRC_OK) check('первый фрагмент разрешён', v.allow_fragment and v.guard == 'ok') for _ in range(config.SOURCE_BUDGET): guards.consume_budget(db, TOK, EPOCH, 'art:1') v = guards.check_result(db, TOK, EPOCH, 'art:1', SRC_OK) check(f'после {config.SOURCE_BUDGET} фрагментов выдача остановлена', not v.allow_fragment and v.guard == 'budget') check('но атрибуция продолжает выдаваться (прямое требование п. 7.5)', v.allow_attribution) check('пользователю объяснена причина', bool(v.reason)) # Другой источник тем же токеном — бюджет независим. v = guards.check_result(db, TOK, EPOCH, 'art:9', {**SRC_OK, 'doc_id': 'art:9'}) check('бюджет считается по каждому источнику отдельно', v.allow_fragment) # Смена эпохи = покупка нового пакета (п. 4.4 ТУ). v = guards.check_result(db, TOK, EPOCH + 1, 'art:1', SRC_OK) check('новый пакет обнуляет бюджет источника', v.allow_fragment) # Старый пакет при этом не воскресает. v = guards.check_result(db, TOK, EPOCH, 'art:1', SRC_OK) check('прежняя эпоха осталась исчерпанной', not v.allow_fragment) check('остаток бюджета считается верно', guards.budget_left(db, TOK, EPOCH, 'art:1') == 0 and guards.budget_left(db, TOK, EPOCH + 1, 'art:1') == config.SOURCE_BUDGET) # Продолжение отрывка стоит столько же, сколько любой другой фрагмент — # замена прежнему запрету смежности. db2 = fresh_db() guards.consume_budget(db2, TOK, EPOCH, 'art:1', 1) check('продолжение расходует бюджет наравне с обычным фрагментом', guards.budget_left(db2, TOK, EPOCH, 'art:1') == config.SOURCE_BUDGET - 1) # ============================================================ print('\nG5 — стоп-лист правообладателя (п. 9.2 ТУ)') # ============================================================ db3 = fresh_db() v = guards.check_result(db3, TOK, EPOCH, 'art:4', SRC_BLOCKED) check('заблокированный источник не выдаёт фрагмент', not v.allow_fragment) check('заблокированный источник не выдаёт даже атрибуцию', not v.allow_attribution) check('в журнал пойдёт причина blocked', v.guard == 'blocked') # Порядок важен: блокировка проверяется РАНЬШЕ бюджета, иначе исчерпание # бюджета раскрыло бы факт существования источника в корпусе. for _ in range(config.SOURCE_BUDGET): guards.consume_budget(db3, TOK, EPOCH, 'art:4') v = guards.check_result(db3, TOK, EPOCH, 'art:4', SRC_BLOCKED) check('блокировка проверяется раньше бюджета', v.guard == 'blocked', f'сработал {v.guard}') v = guards.check_result(db3, TOK, EPOCH, 'art:404', None) check('неизвестный источник не выдаётся', not v.allow_fragment and not v.allow_attribution) v = guards.check_result(db3, TOK, EPOCH, 'art:3', SRC_NOAUTH) check('источник без атрибуции не выдаёт фрагмент', not v.allow_fragment and v.guard == 'no_attribution') # ============================================================ print('\nG4 — ограничение частоты (п. 7.7 ТУ)') # ============================================================ rl = guards.RateLimiter(limit=3, window=10, burst=5) results = [rl.check('t1') for _ in range(10)] check('часть запросов отклонена', not all(results), f'разрешено {sum(results)} из 10') check('первые запросы прошли', results[0] and results[1]) check('другой токен не пострадал', guards.RateLimiter(limit=3, window=10).check('t2')) rl2 = guards.RateLimiter(limit=2, window=1, burst=3) rl2.check('t3'); rl2.check('t3') check('превышение блокируется', not rl2.check('t3')) time.sleep(1.1) check('после окна доступ восстановлен', rl2.check('t3')) # ============================================================ print('\nG6 — журнал (п. 7.9 ТУ)') # ============================================================ db4 = fresh_db() guards.log_access(db4, TOK, 'art:1', 850, 'ok') guards.log_access(db4, TOK, 'art:4', None, 'blocked') db4.commit() cols = [r[1] for r in db4.execute('PRAGMA table_info(access_log)')] check('в журнале нет колонки под текст запроса', not any(c in cols for c in ('query', 'query_text', 'text', 'fragment')), str(cols)) sig_log = str(inspect.signature(guards.log_access)) check('у функции журналирования нет параметра для текстов', not any(w in sig_log for w in ('query', 'text', 'fragment')), sig_log) # Механическая проверка: в дампе базы не должно быть ни текста запроса, # ни текста фрагмента — так же, как этого требует тест приёмки в спеке. dump = '\n'.join(db4.iterdump()) check('дамп журнала не содержит текстов', 'Народные русские сказки' not in dump) old = int(time.time()) - (config.LOG_RETENTION_DAYS + 10) * 86400 db4.execute('INSERT INTO access_log (token, ts, doc_id, chars, guard) VALUES (?,?,?,?,?)', (TOK, old, 'art:1', 100, 'ok')) db4.commit() removed = guards.purge_old_logs(db4) check('записи старше срока хранения удаляются', removed == 1, f'удалено {removed}') check('свежие записи не тронуты', db4.execute('SELECT COUNT(*) FROM access_log').fetchone()[0] == 2) # ============================================================ print('\nБелый список полей ответа') # ============================================================ dirty_source = { **SRC_OK, 'path': '/srv/library/articles/secret.pdf', 'filename': 'secret.pdf', 'source_url': 'https://example.org/download/abc123.pdf', 'has_local_file': True, 'chunk_index': 7, 'char_start': 1024, 'char_end': 1536, } item = guards.build_response_item('Текст фрагмента.', dirty_source, 'art:1', 29) leaks = guards.assert_no_forbidden_fields(item) check('в ответе нет служебных полей', not leaks, f'утекло: {leaks}') blob = repr(item) check('в ответе нет пути к файлу', '/srv/library' not in blob and 'secret.pdf' not in blob) check('в ответе нет ссылки канала получения', 'example.org/download' not in blob) check('в ответе нет смещений в тексте', '1024' not in blob and '1536' not in blob) check('состав ответа соответствует спецификации', set(item) <= {'doc_id', 'attribution', 'source_type', 'budget_left', 'fragment', 'note'}, f'лишние ключи: {set(item) - {"doc_id", "attribution", "source_type", "budget_left", "fragment", "note"}}') check('состав атрибуции соответствует спецификации', set(item['attribution']) == set(guards.ATTRIBUTION_FIELDS), str(sorted(item['attribution']))) # Ответ при исчерпанном бюджете: атрибуция есть, фрагмента нет. item2 = guards.build_response_item(None, SRC_OK, 'art:1', 0, note='лимит выбран') check('при исчерпанном бюджете фрагмент отсутствует', 'fragment' not in item2) check('при исчерпанном бюджете атрибуция присутствует', bool(item2['attribution']['author'])) # ============================================================ print('\nКонфигурация') # ============================================================ check('объём фрагмента не превышает заявленный в п. 7.2 ТУ', config.FRAGMENT_MAX_CHARS <= 1000, str(config.FRAGMENT_MAX_CHARS)) check('бюджет источника соответствует п. 7.5 ТУ', config.SOURCE_BUDGET == 30, str(config.SOURCE_BUDGET)) check('срок хранения журнала соответствует п. 7.9 ТУ', config.LOG_RETENTION_DAYS == 180, str(config.LOG_RETENTION_DAYS)) check('срок действия пакета соответствует п. 4.3 ТУ', config.PACKAGE_DAYS == 90, str(config.PACKAGE_DAYS)) check('ограничение частоты включено', config.RATE_LIMIT >= 1 and config.RATE_WINDOW_SECONDS >= 1) # ============================================================ print(f'\n{"=" * 60}') if FAILED: print(f'ПРОВАЛЕНО: {len(FAILED)} из {PASSED + len(FAILED)}') for f in FAILED: print(f' - {f}') print('Запускать сервис нельзя.') sys.exit(1) print(f'ВСЕ ПРОЙДЕНЫ: {PASSED}') sys.exit(0)