Files
raycast-api/tests/test_files.py
T

183 lines
6.3 KiB
Python

"""Tests for `raycast_api.ai.files.FilesAPI`.
Three endpoints with three different signing quirks:
- POST /ai/files — normal signed JSON body, then unsigned PUT to a
presigned URL on a different host.
- GET /ai/files/{id} — signed body is the LITERAL two-byte string
`"{}"` (not empty!). This is the one trap callers must not break.
- DELETE /ai/files — signed JSON body sent ON a DELETE request.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing import Signer
from raycast_api.signing_spec import RotRange, SigningSpec
REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"
DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099"
FIXED_TIMESTAMP = 1778858809
def _config() -> Config:
return Config(
signature_secret=REFERENCE_SECRET,
signing_spec=SigningSpec(
rot_fn_name="Sur",
signing_fn_name="Nkt",
rot_ranges=[
RotRange(start=65, end=90, shift=13),
RotRange(start=97, end=122, shift=13),
RotRange(start=48, end=57, shift=5),
],
),
app_version="0.60.1.0",
user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)",
bundle_hash="0" * 64,
launcher_hash="0" * 64,
)
def _client() -> Client:
return Client(
config=_config(),
bearer_token="rca_test",
device_id=DEVICE_ID,
clock=lambda: FIXED_TIMESTAMP,
)
class TestUpload:
@pytest.mark.asyncio
async def test_upload_registers_then_puts(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt"
f.write_bytes(b"hi there")
expected_checksum = hashlib.sha256(b"hi there").hexdigest()
captured: dict[str, Any] = {}
def _register_cb(url: Any, **kwargs: Any) -> Any:
captured["register_data"] = kwargs.get("data")
captured["register_headers"] = dict(kwargs.get("headers") or {})
from aioresponses import CallbackResult
return CallbackResult(
status=200,
payload={
"id": "file_abc123",
"direct_upload": {
"url": "https://blobs.example.com/u/abc",
"headers": {"X-Upload-Token": "deadbeef"},
},
},
)
def _put_cb(url: Any, **kwargs: Any) -> Any:
captured["put_data"] = kwargs.get("data")
captured["put_headers"] = dict(kwargs.get("headers") or {})
from aioresponses import CallbackResult
return CallbackResult(status=200)
with aioresponses() as mocked:
mocked.post(
"https://backend.raycast.com/api/v1/ai/files", callback=_register_cb
)
mocked.put("https://blobs.example.com/u/abc", callback=_put_cb)
async with _client() as client:
meta = await client.files.upload(path=f, chat_id="chat_1")
assert meta.file_id == "file_abc123"
assert meta.checksum == expected_checksum
body = captured["register_data"]
if hasattr(body, "_value"):
body = body._value
import json as _json
parsed = _json.loads(bytes(body))
assert parsed == {
"chat_id": "chat_1",
"blob": {
"filename": "hello.txt",
"byte_size": 8,
"content_type": "text/plain",
"checksum": expected_checksum,
},
}
assert "X-Raycast-Signature-v2" in captured["register_headers"]
assert "X-Raycast-Signature-v2" not in captured["put_headers"]
assert "Authorization" not in captured["put_headers"]
assert captured["put_headers"]["X-Upload-Token"] == "deadbeef"
assert bytes(captured["put_data"]) == b"hi there"
class TestGetSignsBraceBody:
@pytest.mark.asyncio
async def test_get_signs_two_byte_brace_body(self) -> None:
"""⚠ `GET /ai/files/{id}` signs the literal string `"{}"`, not `""`.
This is the BUNDLE_NOTES "surprise" — `uV` differs from the resume
GET. Server validates against the bytes the client claims to have
sent, so we must match.
"""
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["headers"] = dict(kwargs.get("headers") or {})
captured["data"] = kwargs.get("data")
from aioresponses import CallbackResult
return CallbackResult(status=200, body=b"file-contents")
with aioresponses() as mocked:
mocked.get("https://backend.raycast.com/api/v1/ai/files/F1", callback=_cb)
async with _client() as client:
data = await client.files.get("F1")
assert data == b"file-contents"
wire_body = captured["data"]
if hasattr(wire_body, "_value"):
wire_body = wire_body._value
assert bytes(wire_body) == b"{}"
signer = Signer(spec=_config().signing_spec, secret=REFERENCE_SECRET)
expected = signer.sign(
timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=b"{}"
)
assert captured["headers"]["X-Raycast-Signature-v2"] == expected
class TestDelete:
@pytest.mark.asyncio
async def test_delete_with_chat_ids_body(self) -> None:
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["headers"] = dict(kwargs.get("headers") or {})
captured["data"] = kwargs.get("data")
from aioresponses import CallbackResult
return CallbackResult(status=204)
with aioresponses() as mocked:
mocked.delete("https://backend.raycast.com/api/v1/ai/files", callback=_cb)
async with _client() as client:
await client.files.delete(chat_ids=["c1", "c2"])
body = captured["data"]
if hasattr(body, "_value"):
body = body._value
assert bytes(body) == b'{"chat_ids":["c1","c2"]}'
assert "X-Raycast-Signature-v2" in captured["headers"]
assert captured["headers"]["Content-Type"] == "application/json"