183 lines
6.3 KiB
Python
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"
|