Compare commits
7
Commits
f7687d3f12
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1750a8497f | ||
|
|
09016b22e8 | ||
|
|
523b52a26c | ||
|
|
67a4caa43c | ||
|
|
05a035471d | ||
|
|
bfe487164f | ||
|
|
df0576aebd |
@@ -0,0 +1,14 @@
|
|||||||
|
# 2026-09-06: план дополнительных hardening-тестов
|
||||||
|
|
||||||
|
После полного прогона `0.2.2` выявлены тестовые пробелы, которые не покрывались штатными 10 Python и 10 Node тестами:
|
||||||
|
|
||||||
|
- границы `MAX_FILE_BYTES` (ровно лимит и лимит + 1 байт);
|
||||||
|
- превышение фактического размера файла после pull при заниженном заявленном размере;
|
||||||
|
- граница `MAX_SESSION_BYTES`;
|
||||||
|
- retry pull после временного HTTP-сбоя;
|
||||||
|
- удаление сессии по TTL;
|
||||||
|
- удаление файла из буфера при пропуске по лимиту;
|
||||||
|
- отмена frontend после частично завершённого пофайлового транзита;
|
||||||
|
- повторный выбор/двойной запуск picker без параллельной обработки.
|
||||||
|
|
||||||
|
Первым шагом добавляются backend hardening-тесты для лимитов, retry, TTL и очистки буфера. После каждого логического изменения выполняется отдельный коммит и проверка.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 2026-09-06: результат дополнительных hardening-тестов
|
||||||
|
|
||||||
|
## Добавленные тесты
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- точное достижение `MAX_SESSION_BYTES` и отказ при переполнении;
|
||||||
|
- отказ при фактическом размере файла больше `MAX_FILE_BYTES`, даже если заявленный размер допустим;
|
||||||
|
- обязательное удаление oversize-файла из буфера;
|
||||||
|
- retry pull после временного HTTP `503`;
|
||||||
|
- удаление файла после успешного pull;
|
||||||
|
- автоматическое удаление RAM-сессии по короткому TTL.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- отмена после первого успешно доставленного файла;
|
||||||
|
- подтверждение, что второй `PUT` не начинается;
|
||||||
|
- подтверждение частичного результата и `aborted: true`;
|
||||||
|
- проверка callback статуса доставленного первого файла.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- hardening backend: `4/4` PASS;
|
||||||
|
- полный Node suite: `11/11` PASS;
|
||||||
|
- полный Python suite: `14/14` PASS;
|
||||||
|
- syntax sweep: `22` Python-файла и `13` JavaScript-файлов PASS;
|
||||||
|
- frontend build: PASS;
|
||||||
|
- runtime `/health`: `ok`;
|
||||||
|
- runtime `/`: HTTP 200, `10035` байт;
|
||||||
|
- `git diff --check`: PASS.
|
||||||
|
|
||||||
|
## Исправленные ошибки тестового harness
|
||||||
|
|
||||||
|
- Для backend pull использован корректный `httpx.WSGITransport`.
|
||||||
|
- Конфигурационный ключ лимита приведён к API-формату `maxFileBytes`.
|
||||||
|
- Timing partial-cancel теста синхронизирован с первым `PUT`.
|
||||||
|
- Assertion статуса приведён к фактическому callback-тексту `✓ Доставлен`.
|
||||||
|
|
||||||
|
Production-код не изменялся, поэтому версия остаётся `0.2.2`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 2026-09-06: полный тест релиза 0.2.2
|
||||||
|
|
||||||
|
## Релиз
|
||||||
|
|
||||||
|
Проверен коммит `f7687d3` с версией `0.2.2`. Локальная ветка на момент проверки совпадала с `origin/master`.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- `npm run build` — успешно;
|
||||||
|
- `npm test` — `10/10` Node-тестов пройдены;
|
||||||
|
- `python3 -m pytest tests/ -v` — `10/10` Python-тестов пройдены;
|
||||||
|
- `python3 -m py_compile site/app.py` — успешно;
|
||||||
|
- `node --version` — `v18.19.1`;
|
||||||
|
- `python` — `3.12.3`;
|
||||||
|
- `git diff --check` — без замечаний;
|
||||||
|
- запуск `python3 site/app.py` — успешный, Flask слушает `0.0.0.0:5000`;
|
||||||
|
- живой `GET /health` — HTTP 200, тело `ok`;
|
||||||
|
- живой `GET /` — HTTP 200, получено `10035` байт HTML.
|
||||||
|
|
||||||
|
## Состав Node-тестов
|
||||||
|
|
||||||
|
Проверены:
|
||||||
|
|
||||||
|
- рендер папок по умолчанию в свёрнутом состоянии;
|
||||||
|
- успешный PUT в буфер ВМ;
|
||||||
|
- HTTP-ошибка PUT;
|
||||||
|
- сетевая ошибка;
|
||||||
|
- таймаут;
|
||||||
|
- отмена через `AbortSignal`;
|
||||||
|
- пофайловый транзит Layer 2;
|
||||||
|
- формат файлов `FilePicker.getFiles()`;
|
||||||
|
- ошибка PUT в `uploadViaVM`;
|
||||||
|
- отмена загрузки через signal.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
Релиз `0.2.2` проходит полный автоматический и локальный runtime-прогон. После документирования запись должна быть отдельным коммитом и отправлена в `origin/master`.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 2026-09-06: жёсткий полный тест релиза 0.2.2
|
||||||
|
|
||||||
|
## Объём проверки
|
||||||
|
|
||||||
|
Проверен опубликованный релиз `0.2.2`, commit `df0576a` на `origin/master`.
|
||||||
|
|
||||||
|
- frontend build;
|
||||||
|
- весь Node test suite;
|
||||||
|
- весь Python test suite с `-vv --tb=long`;
|
||||||
|
- синтаксис всех исходных Python и JavaScript файлов;
|
||||||
|
- runtime запуск Flask;
|
||||||
|
- HTTP endpoints и раздача собранных бандлов;
|
||||||
|
- отрицательные API-сценарии безопасности и целостности транзита.
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- `npm run build` — PASS;
|
||||||
|
- `npm test` — `10/10` PASS;
|
||||||
|
- `python3 -m pytest tests/ -vv --tb=long` — `10/10` PASS;
|
||||||
|
- `py_compile` — `22` Python-файла PASS;
|
||||||
|
- `node --check` — `13` JavaScript-файлов PASS;
|
||||||
|
- `git diff --check` — PASS;
|
||||||
|
- `/health` — HTTP 200, тело `ok`;
|
||||||
|
- `/` — HTTP 200;
|
||||||
|
- `/file-picker/file-picker.esm.js` — HTTP 200;
|
||||||
|
- `/file-picker/file-picker.iife.js` — HTTP 200;
|
||||||
|
- `/upload-frontend/index.js` — HTTP 200;
|
||||||
|
- Flask слушает `0.0.0.0:5000`.
|
||||||
|
|
||||||
|
## Отрицательные сценарии
|
||||||
|
|
||||||
|
Отдельный smoke-набор подтвердил:
|
||||||
|
|
||||||
|
- пустой `upload_refs` возвращает HTTP 400;
|
||||||
|
- malformed JSON возвращает HTTP 400;
|
||||||
|
- traversal-имя не попадает в сессию и не удаляет файл из буфера;
|
||||||
|
- SSRF URL вне разрешённого prefix пропускается без pull;
|
||||||
|
- валидный файл создаёт новую RAM-сессию;
|
||||||
|
- файл переносится в RAM;
|
||||||
|
- callback Layer 3 вызывается;
|
||||||
|
- файл удаляется из mock buffer после успешного pull;
|
||||||
|
- повторное чтение удалённого файла возвращает 404.
|
||||||
|
|
||||||
|
## Исправления harness во время проверки
|
||||||
|
|
||||||
|
Два первых запуска расширенного smoke-набора остановились в тестовом harness, не в приложении:
|
||||||
|
|
||||||
|
1. Для in-process Flask pull не был задан `httpx.WSGITransport`, поэтому backend получил `Connection refused`.
|
||||||
|
2. Следующий запуск передал несуществующий `session`; API корректно вернул сценарий `Session not found` вместо создания сессии. Новая сессия создаётся при отсутствии поля `session`.
|
||||||
|
|
||||||
|
После исправления harness smoke-набор завершился `strict_negative_smoke=PASS`.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Релиз `0.2.2` прошёл полный автоматический, синтаксический, runtime и отрицательный security-тест. Код не изменялся, поэтому версию не повышали.
|
||||||
Vendored
-1194
File diff suppressed because it is too large
Load Diff
Vendored
-1218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
import threading
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from upload.backend.session import add_file, cleanup, configure, create_session, get_files
|
||||||
|
from upload.backend.session import state as session_state
|
||||||
|
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(transport, **config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['UPLOAD_HTTPX_TRANSPORT'] = transport
|
||||||
|
app.register_blueprint(create_upload_refs_blueprint({
|
||||||
|
'vmUploadPrefix': 'https://vm.test/buffer/',
|
||||||
|
'httpxTransport': transport,
|
||||||
|
'pullRetries': 3,
|
||||||
|
'pullRetryDelay': 0,
|
||||||
|
**config,
|
||||||
|
}))
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_limit_accepts_exact_boundary_and_rejects_overflow():
|
||||||
|
sid = create_session()
|
||||||
|
previous = session_state.MAX_SESSION_BYTES
|
||||||
|
configure(max_session_bytes=10)
|
||||||
|
try:
|
||||||
|
assert add_file(sid, 'exact.bin', b'x' * 10) is True
|
||||||
|
assert add_file(sid, 'overflow.bin', b'x') is False
|
||||||
|
assert len(get_files(sid)) == 1
|
||||||
|
finally:
|
||||||
|
configure(max_session_bytes=previous)
|
||||||
|
cleanup(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_refs_rejects_actual_oversize_and_deletes_buffer():
|
||||||
|
storage = {'oversize': b'123456789'}
|
||||||
|
deleted = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
return httpx.Response(200, content=storage['oversize'])
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
deleted.append(request.url.path)
|
||||||
|
storage.clear()
|
||||||
|
return httpx.Response(204)
|
||||||
|
return httpx.Response(405)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
app = build_app(transport, maxFileBytes=8)
|
||||||
|
sid = create_session()
|
||||||
|
try:
|
||||||
|
with app.test_client() as client:
|
||||||
|
response = client.post('/api/upload_refs', json={
|
||||||
|
'session': sid,
|
||||||
|
'files': [{
|
||||||
|
'name': 'oversize.txt',
|
||||||
|
'size': 8,
|
||||||
|
'url': 'https://vm.test/buffer/oversize',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['added'] == 0
|
||||||
|
assert get_files(sid) == []
|
||||||
|
assert deleted == ['/buffer/oversize']
|
||||||
|
assert storage == {}
|
||||||
|
finally:
|
||||||
|
cleanup(sid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_refs_retries_transient_pull_and_deletes_after_success():
|
||||||
|
attempts = []
|
||||||
|
deleted = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
attempts.append(request.url.path)
|
||||||
|
if len(attempts) == 1:
|
||||||
|
return httpx.Response(503)
|
||||||
|
return httpx.Response(200, content=b'retry-content')
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
deleted.append(request.url.path)
|
||||||
|
return httpx.Response(204)
|
||||||
|
return httpx.Response(405)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
app = build_app(transport)
|
||||||
|
with app.test_client() as client:
|
||||||
|
response = client.post('/api/upload_refs', json={'files': [{
|
||||||
|
'name': 'retry.txt',
|
||||||
|
'size': 13,
|
||||||
|
'url': 'https://vm.test/buffer/retry',
|
||||||
|
}]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['added'] == 1
|
||||||
|
assert len(attempts) == 2
|
||||||
|
assert deleted == ['/buffer/retry']
|
||||||
|
cleanup(data['session'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_ttl_removes_session():
|
||||||
|
previous = session_state.TTL_SECONDS
|
||||||
|
configure(ttl_seconds=0.05)
|
||||||
|
sid = create_session()
|
||||||
|
try:
|
||||||
|
deadline = monotonic() + 1
|
||||||
|
while monotonic() < deadline and get_files(sid) is not None:
|
||||||
|
threading.Event().wait(0.01)
|
||||||
|
assert get_files(sid) is None
|
||||||
|
finally:
|
||||||
|
configure(ttl_seconds=previous)
|
||||||
|
cleanup(sid)
|
||||||
@@ -231,3 +231,32 @@ test('uploadViaVM: прерывание через signal', async () => {
|
|||||||
assert.equal(res.ok, false);
|
assert.equal(res.ok, false);
|
||||||
assert.equal(res.aborted, true);
|
assert.equal(res.aborted, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uploadViaVM: отмена после первого файла не начинает второй PUT', async () => {
|
||||||
|
installFetch(async (url, opts) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true, session: 'partial-sid', count: 1 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const statuses = [];
|
||||||
|
const files = [new File(['first'], 'first.txt'), new File(['second'], 'second.txt')];
|
||||||
|
const p = uploadViaVM(files, {
|
||||||
|
vmUploadUrl: 'https://vm-buffer/upload/',
|
||||||
|
signal: ac.signal,
|
||||||
|
onFileStatus: (idx, status) => statuses.push({ idx, status }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
assert.equal(lastXHR.url.endsWith('_0'), true);
|
||||||
|
lastXHR.status = 201;
|
||||||
|
lastXHR.onload();
|
||||||
|
ac.abort();
|
||||||
|
const res = await p;
|
||||||
|
|
||||||
|
assert.equal(res.ok, false);
|
||||||
|
assert.equal(res.aborted, true);
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
assert.equal(lastXHR.url.endsWith('_0'), true);
|
||||||
|
assert.ok(statuses.some(({ idx, status }) => idx === 0 && status.includes('Доставлен')));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user