Files

601 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Integration tests for backup APIs (list/get/restore/create/delete/
export/import).
"""
from __future__ import annotations
import io
import json
import zipfile
import pytest
from helpers import default_http_timeout
_BACKUP_HTTP_TIMEOUT = default_http_timeout(30.0)
# --------------------------------------------------------------------------- #
# shared helpers for export / import cases
# --------------------------------------------------------------------------- #
_DEFAULT_BACKUP_PAYLOAD = {
"scope": {
"include_agents": False,
"include_global_config": False,
"include_secrets": False,
"include_skill_pool": True,
},
"agents": [],
}
def _create_backup(app_server, *, name: str, description: str = "") -> str:
"""Create a minimal backup via stream and return its id."""
payload = dict(_DEFAULT_BACKUP_PAYLOAD)
payload["name"] = name
payload["description"] = description or f"integration backup {name}"
meta = _stream_create_backup(app_server, payload)
backup_id = meta.get("id")
assert isinstance(backup_id, str) and backup_id
return backup_id
def _delete_backup(app_server, backup_id: str) -> None:
"""Best-effort backup delete used by finally blocks."""
app_server.api_request(
"POST",
"/api/backups/delete",
json={"ids": [backup_id]},
timeout=_BACKUP_HTTP_TIMEOUT,
)
def _export_backup_bytes(app_server, backup_id: str) -> bytes:
"""Hit the export endpoint and return the raw zip bytes."""
resp = app_server.api_request(
"GET",
f"/api/backups/{backup_id}/export",
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 200, app_server.logs_tail()
assert "zip" in resp.headers.get("content-type", "").lower()
return resp.content
def _list_backup_ids(app_server) -> set[str]:
resp = app_server.api_request(
"GET",
"/api/backups",
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 200, app_server.logs_tail()
return {item.get("id") for item in resp.json()}
def _stream_create_backup(app_server, payload: dict) -> dict:
"""POST /api/backups/stream and return the 'meta' dict from the final
``done`` event.
The endpoint emits ``data: {...}\\n\\n`` SSE frames; we walk them until
a ``done`` event arrives, raising AssertionError on ``error`` events.
"""
url = f"{app_server.base_url}/api/backups/stream"
with app_server.client.stream(
"POST",
url,
json=payload,
timeout=_BACKUP_HTTP_TIMEOUT,
) as resp:
assert resp.status_code == 200, (
f"create stream returned {resp.status_code}; "
f"logs: {app_server.logs_tail()}"
)
meta: dict | None = None
for line in resp.iter_lines():
if not line or not line.startswith("data:"):
continue
event = json.loads(line[len("data:") :].strip())
if event.get("type") == "error":
raise AssertionError(
f"backup create errored: {event} | "
f"logs: {app_server.logs_tail()}",
)
if event.get("type") == "done":
meta = event["meta"]
break
assert meta is not None, (
"backup stream ended without a 'done' event; "
f"logs: {app_server.logs_tail()}"
)
return meta
@pytest.mark.integration
@pytest.mark.p0
def test_backup_get_detail_returns_404_for_missing(app_server) -> None:
"""Test purpose:
- Verify GET /api/backups/{backup_id} returns 404 when the backup id
is not present, so the console reliably distinguishes missing vs
malformed backups.
Test flow:
1. GET /api/backups/{nonexistent_id}.
2. Assert 404 status and ``detail`` == ``Backup not found``.
API endpoints:
- GET /api/backups/{backup_id}
"""
resp = app_server.api_request(
"GET",
"/api/backups/qwenpaw-missing-integ-0001",
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 404, app_server.logs_tail()
assert resp.json().get("detail") == "Backup not found"
@pytest.mark.integration
@pytest.mark.p0
def test_backup_restore_returns_404_for_missing(app_server) -> None:
"""Test purpose:
- Verify POST /api/backups/{backup_id}/restore returns 404 when the
backup id does not exist, preventing silent no-op restores.
Test flow:
1. POST /api/backups/{nonexistent_id}/restore with a minimal restore
body (no agents, no secrets).
2. Assert 404 status and ``detail`` == ``Backup not found``.
API endpoints:
- POST /api/backups/{backup_id}/restore
"""
resp = app_server.api_request(
"POST",
"/api/backups/qwenpaw-missing-integ-0002/restore",
json={
"include_agents": False,
"agent_ids": [],
"include_global_config": False,
"include_secrets": False,
"include_skill_pool": False,
"mode": "custom",
},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 404, app_server.logs_tail()
assert resp.json().get("detail") == "Backup not found"
@pytest.mark.integration
@pytest.mark.p0
def test_backup_create_stream_and_restore_lifecycle(app_server) -> None:
"""Test purpose:
- Verify the full backup CRUD lifecycle: create via SSE stream, list,
detail, restore, delete. A regression on any step makes the
backup/restore feature unusable, so the lifecycle is P0.
Test flow:
1. POST /api/backups/stream with a minimal scope (no agents/secrets,
skill_pool only) and consume SSE events until ``done`` arrives.
2. Capture ``backup_id`` from the final ``done`` event meta.
3. GET /api/backups and assert the new id is present in the listing.
4. GET /api/backups/{backup_id} and assert detail matches the id.
5. POST /api/backups/{backup_id}/restore with a no-op restore body
(no agents, no secrets, no global config); assert 200 and
``ok`` == True.
6. POST /api/backups/delete with the id; assert it is reported in
``deleted``.
7. GET /api/backups and assert the id is no longer present.
API endpoints:
- POST /api/backups/stream
- GET /api/backups
- GET /api/backups/{backup_id}
- POST /api/backups/{backup_id}/restore
- POST /api/backups/delete
"""
create_payload = {
"name": "integ-backup-lifecycle-01",
"description": "integration backup lifecycle",
"scope": {
"include_agents": False,
"include_global_config": False,
"include_secrets": False,
"include_skill_pool": True,
},
"agents": [],
}
meta = _stream_create_backup(app_server, create_payload)
backup_id = meta.get("id")
assert isinstance(backup_id, str) and backup_id
assert meta.get("name") == "integ-backup-lifecycle-01"
try:
list_resp = app_server.api_request(
"GET",
"/api/backups",
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert list_resp.status_code == 200, app_server.logs_tail()
listed_ids = {item.get("id") for item in list_resp.json()}
assert backup_id in listed_ids
detail_resp = app_server.api_request(
"GET",
f"/api/backups/{backup_id}",
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert detail_resp.status_code == 200, app_server.logs_tail()
assert detail_resp.json().get("id") == backup_id
restore_resp = app_server.api_request(
"POST",
f"/api/backups/{backup_id}/restore",
json={
"include_agents": False,
"agent_ids": [],
"include_global_config": False,
"include_secrets": False,
"include_skill_pool": False,
"mode": "custom",
},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert restore_resp.status_code == 200, app_server.logs_tail()
assert restore_resp.json().get("ok") is True
finally:
delete_resp = app_server.api_request(
"POST",
"/api/backups/delete",
json={"ids": [backup_id]},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert delete_resp.status_code == 200, app_server.logs_tail()
deleted_payload = delete_resp.json()
assert backup_id in deleted_payload.get("deleted", [])
list_after = app_server.api_request(
"GET",
"/api/backups",
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert list_after.status_code == 200, app_server.logs_tail()
remaining = {item.get("id") for item in list_after.json()}
assert backup_id not in remaining
# --------------------------------------------------------------------------- #
# Sprint 1.3 — export + import coverage
# --------------------------------------------------------------------------- #
@pytest.mark.integration
@pytest.mark.p1
def test_backup_export_returns_404_for_missing(app_server) -> None:
"""Test purpose:
- Verify GET /api/backups/{backup_id}/export returns 404 when the
backup id does not exist, so Console can tell a real export error
apart from a silent failure.
Test flow:
1. GET /api/backups/<synthetic-missing>/export.
2. Assert 404 + ``detail`` == "Backup not found".
API endpoints:
- GET /api/backups/{backup_id}/export
"""
resp = app_server.api_request(
"GET",
"/api/backups/qwenpaw-missing-export-integ-0001/export",
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 404, app_server.logs_tail()
assert resp.json().get("detail") == "Backup not found"
@pytest.mark.integration
@pytest.mark.p1
def test_backup_export_returns_valid_zip_with_manifest(app_server) -> None:
"""Test purpose:
- Verify export returns a real zip stream whose archive contains
the backup manifest. Console relies on the manifest entry to
validate the archive client-side before triggering a restore.
Test flow:
1. Create a backup via stream; capture ``backup_id``.
2. GET export and assert the response Content-Type is zip.
3. Open response bytes with ``zipfile.ZipFile``; assert it parses
and includes a backup.json / manifest.json file with the same
``backup_id``.
4. finally — delete the backup.
API endpoints:
- POST /api/backups/stream
- GET /api/backups/{backup_id}/export
- POST /api/backups/delete
"""
backup_id = _create_backup(app_server, name="integ-export-zip-01")
try:
zip_bytes = _export_backup_bytes(app_server, backup_id)
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = zf.namelist()
# Manifest filename is implementation-defined; accept the
# common shapes (backup.json / manifest.json) and verify the
# backup_id appears somewhere in its contents.
# Real archives store the manifest as ``meta.json`` at the
# root; keep the alternatives accepted in case the layout
# evolves (manifest.json / backup.json).
manifest_name = next(
(
n
for n in names
if n.endswith(
("meta.json", "manifest.json", "backup.json"),
)
),
None,
)
assert manifest_name is not None, f"no manifest in zip: {names}"
manifest_text = zf.read(manifest_name).decode("utf-8")
assert (
backup_id in manifest_text
), f"backup_id missing from manifest: {manifest_text[:200]}"
finally:
_delete_backup(app_server, backup_id)
@pytest.mark.integration
@pytest.mark.p0
def test_backup_export_delete_import_restores_same_backup(app_server) -> None:
"""Test purpose:
- Verify the disaster-recovery path: a user accidentally deletes a
backup, then imports the previously-downloaded zip to recover it.
The recovered backup keeps the same id (manifest-driven), so the
restore flow afterwards behaves identically.
Test flow:
1. Create backup A; export it (zip bytes).
2. Delete A; assert A is no longer in the list.
3. POST /api/backups/import as multipart with the zip bytes.
4. Assert 200 and the returned BackupMeta keeps id == A's id.
5. GET /api/backups; assert A is back in the list.
6. finally — delete A.
API endpoints:
- POST /api/backups/stream
- GET /api/backups/{backup_id}/export
- POST /api/backups/delete
- POST /api/backups/import
- GET /api/backups
"""
backup_id = _create_backup(app_server, name="integ-recovery-01")
try:
zip_bytes = _export_backup_bytes(app_server, backup_id)
_delete_backup(app_server, backup_id)
assert backup_id not in _list_backup_ids(app_server)
import_resp = app_server.api_request(
"POST",
"/api/backups/import",
files={
"file": (
f"{backup_id}.zip",
zip_bytes,
"application/zip",
),
},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert import_resp.status_code == 200, app_server.logs_tail()
meta = import_resp.json()
assert meta.get("id") == backup_id
assert backup_id in _list_backup_ids(app_server)
finally:
_delete_backup(app_server, backup_id)
@pytest.mark.integration
@pytest.mark.p2
def test_backup_import_no_file_returns_400(app_server) -> None:
"""Test purpose:
- Verify POST /api/backups/import with neither ``file`` nor
``pending_token`` returns 400, preventing silent no-op imports.
Test flow:
1. POST /api/backups/import with an empty multipart body
(no file, no token).
2. Assert 400 + ``detail`` == "file is required".
API endpoints:
- POST /api/backups/import
"""
resp = app_server.api_request(
"POST",
"/api/backups/import",
files={"_": ("placeholder", b"", "text/plain")},
timeout=_BACKUP_HTTP_TIMEOUT,
)
# Some httpx builds collapse an entirely-empty multipart body, hence
# the throwaway placeholder above. The server still sees no ``file``
# form field and should reject.
assert resp.status_code == 400, app_server.logs_tail()
assert resp.json().get("detail") == "file is required"
@pytest.mark.integration
@pytest.mark.p2
def test_backup_import_non_zip_content_type_returns_400(app_server) -> None:
"""Test purpose:
- Verify upload with a non-zip Content-Type is rejected with 400
before the server tries to parse the archive, so a stray .txt
upload doesn't waste cycles on extraction.
Test flow:
1. POST /api/backups/import multipart file with
Content-Type=text/plain.
2. Assert 400 + ``detail`` containing "Expected a zip file".
API endpoints:
- POST /api/backups/import
"""
resp = app_server.api_request(
"POST",
"/api/backups/import",
files={
"file": (
"not_a_backup.txt",
b"this is plain text, not a zip",
"text/plain",
),
},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 400, app_server.logs_tail()
assert "Expected a zip file" in resp.json().get("detail", "")
@pytest.mark.integration
@pytest.mark.p2
def test_backup_import_corrupted_zip_returns_400(app_server) -> None:
"""Test purpose:
- Verify a payload that *claims* to be a zip but is not parseable
yields a 400 (via BackupValidationError) rather than a 500.
Test flow:
1. POST /api/backups/import with random bytes labelled
``application/zip`` and a .zip filename.
2. Assert 400 (extraction / validation rejection).
API endpoints:
- POST /api/backups/import
"""
resp = app_server.api_request(
"POST",
"/api/backups/import",
files={
"file": (
"corrupted.zip",
b"\x00\x01\x02 not a zip body \x03\x04\x05",
"application/zip",
),
},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 400, app_server.logs_tail()
@pytest.mark.integration
@pytest.mark.p1
def test_backup_import_conflict_returns_409_with_pending_token(
app_server,
) -> None:
"""Test purpose:
- Verify importing a zip whose backup_id is already present returns
409 with a ``pending_token`` and the existing meta, so the client
can prompt the user "overwrite?" without re-uploading the file.
Test flow:
1. Create backup A; export it (zip bytes).
2. POST /api/backups/import the exported zip (A is still on the
server).
3. Assert 409, the response JSON contains ``pending_token`` and
``existing`` whose id matches A.
4. finally — delete A. The temp pending upload may stay behind; the
app's _cleanup_stale_uploads will eventually reap it.
API endpoints:
- POST /api/backups/stream
- GET /api/backups/{backup_id}/export
- POST /api/backups/import
- POST /api/backups/delete
"""
backup_id = _create_backup(app_server, name="integ-conflict-01")
try:
zip_bytes = _export_backup_bytes(app_server, backup_id)
resp = app_server.api_request(
"POST",
"/api/backups/import",
files={
"file": (
f"{backup_id}.zip",
zip_bytes,
"application/zip",
),
},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert resp.status_code == 409, app_server.logs_tail()
payload = resp.json()
assert isinstance(payload.get("pending_token"), str)
assert payload["pending_token"]
existing = payload.get("existing") or {}
assert existing.get("id") == backup_id
finally:
_delete_backup(app_server, backup_id)
@pytest.mark.integration
@pytest.mark.p1
def test_backup_import_with_pending_token_completes_overwrite(
app_server,
) -> None:
"""Test purpose:
- Verify the two-step overwrite flow: after a 409 the client can
POST again with only the ``pending_token`` (no re-upload) to
commit the overwrite. Console's "import and overwrite" button
exercises this exact sequence.
Test flow:
1. Create backup A; export it (zip bytes).
2. POST import — expect 409 and capture pending_token.
3. POST import again as form-encoded body containing only
``pending_token`` (no ``file`` field).
4. Assert 200, returned BackupMeta has id == A.
5. GET /api/backups; assert A still present (overwrite, not delete).
6. finally — delete A.
API endpoints:
- POST /api/backups/stream
- GET /api/backups/{backup_id}/export
- POST /api/backups/import (×2)
- GET /api/backups
- POST /api/backups/delete
"""
backup_id = _create_backup(app_server, name="integ-overwrite-01")
try:
zip_bytes = _export_backup_bytes(app_server, backup_id)
conflict_resp = app_server.api_request(
"POST",
"/api/backups/import",
files={
"file": (
f"{backup_id}.zip",
zip_bytes,
"application/zip",
),
},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert conflict_resp.status_code == 409, app_server.logs_tail()
pending_token = conflict_resp.json().get("pending_token")
assert isinstance(pending_token, str) and pending_token
commit_resp = app_server.api_request(
"POST",
"/api/backups/import",
data={"pending_token": pending_token},
timeout=_BACKUP_HTTP_TIMEOUT,
)
assert commit_resp.status_code == 200, app_server.logs_tail()
meta = commit_resp.json()
assert meta.get("id") == backup_id
assert backup_id in _list_backup_ids(app_server)
finally:
_delete_backup(app_server, backup_id)