Spaces:
Running
Running
Deploy PR #13: Robinhood refresh wait for newer snapshot (11e93ece)
Browse files- README.md +17 -2
- main.py +125 -2
- test_robinhood.py +124 -11
README.md
CHANGED
|
@@ -46,8 +46,11 @@ Copy the Grok Bot webhook URL and sender key into the Space secrets. Do **not**
|
|
| 46 |
- `POST /api/chat` `{ sessionId, clientMsgId, botId, botName, text, history? }` → `{ ok, clientMsgId }` and forwards JSON to `GROK_WEBHOOK_URL`
|
| 47 |
- `GET /api/chat?sessionId=` → `{ messages: [...] }`
|
| 48 |
- `POST /api/chat/reply` `Authorization: Bearer ${CHAT_BRIDGE_SECRET}` `{ sessionId, clientMsgId, botId, text, status: "partial"|"final" }`
|
| 49 |
-
- `GET /api/robinhood` → `{ "snapshot": null }` or `{ "snapshot": { ... } }` (no auth, `Cache-Control: no-store`)
|
| 50 |
-
- `POST /api/robinhood` `Authorization: Bearer ${ROBINHOOD_BRIDGE_SECRET}` replaces the stored snapshot. If that secret is unset, `CHAT_BRIDGE_SECRET` is accepted instead.
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
CORS allows `https://simzy420.github.io` and localhost. Text max 8000. `sessionId` must be a UUID.
|
| 53 |
|
|
@@ -79,3 +82,15 @@ Content-Type: application/json
|
|
| 79 |
```
|
| 80 |
|
| 81 |
Push Casey’s default individual brokerage account. Empty nickname → `"label": "Individual"`. Equity positions only in `positions`. `price` / `marketValue` of `0` or `null` means no live quote.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
- `POST /api/chat` `{ sessionId, clientMsgId, botId, botName, text, history? }` → `{ ok, clientMsgId }` and forwards JSON to `GROK_WEBHOOK_URL`
|
| 47 |
- `GET /api/chat?sessionId=` → `{ messages: [...] }`
|
| 48 |
- `POST /api/chat/reply` `Authorization: Bearer ${CHAT_BRIDGE_SECRET}` `{ sessionId, clientMsgId, botId, text, status: "partial"|"final" }`
|
| 49 |
+
- `GET /api/robinhood` → `{ "snapshot": null, "refreshPending": false }` or `{ "snapshot": { ... }, "refreshPending": false }` (no auth, `Cache-Control: no-store`). When a refresh is waiting on a newer snapshot, the body also includes `refreshPending: true` and `refreshRequestedAt`.
|
| 50 |
+
- `POST /api/robinhood` `Authorization: Bearer ${ROBINHOOD_BRIDGE_SECRET}` replaces the stored snapshot and clears any pending refresh. If that secret is unset, `CHAT_BRIDGE_SECRET` is accepted instead.
|
| 51 |
+
- `POST /api/robinhood/refresh` (public, no auth) stores a pending refresh. Rate limit is one request per 15 seconds per IP. Same path without the `/api` prefix.
|
| 52 |
+
- `GET /api/robinhood/pending-refresh` (public) → `{ "refreshPending": bool, "refreshRequestedAt"?: string }` for Chief of Staff to poll without the snapshot body.
|
| 53 |
+
- `DELETE /api/robinhood/refresh` uses the same bearer as the snapshot POST and clears the pending file. A successful snapshot POST already clears it.
|
| 54 |
|
| 55 |
CORS allows `https://simzy420.github.io` and localhost. Text max 8000. `sessionId` must be a UUID.
|
| 56 |
|
|
|
|
| 82 |
```
|
| 83 |
|
| 84 |
Push Casey’s default individual brokerage account. Empty nickname → `"label": "Individual"`. Equity positions only in `positions`. `price` / `marketValue` of `0` or `null` means no live quote.
|
| 85 |
+
|
| 86 |
+
### Phone refresh (Chief of Staff must fulfill)
|
| 87 |
+
|
| 88 |
+
The phone never calls Robinhood. **Refresh** does `POST /api/robinhood/refresh`, which writes `robinhood-refresh.json` next to the snapshot:
|
| 89 |
+
|
| 90 |
+
```json
|
| 91 |
+
{ "requestedAt": "2026-09-28T15:04:00Z" }
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
`refreshPending` is true when that file exists and there is no snapshot yet, or `requestedAt` is later than `snapshot.updatedAt`.
|
| 95 |
+
|
| 96 |
+
Chief of Staff (or a webhook) must notice the pending flag — poll `GET /api/robinhood` or `GET /api/robinhood/pending-refresh` — pull Robinhood, and `POST /api/robinhood` with a new snapshot. That POST clears the pending file. This repo does not change CoS routines. Until the push lands, the phone keeps the last snapshot and, after about 45 seconds, shows a waiting error.
|
main.py
CHANGED
|
@@ -119,6 +119,92 @@ def save_snapshot(snapshot: dict[str, Any]) -> None:
|
|
| 119 |
tmp.replace(path)
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def robinhood_secret() -> str:
|
| 123 |
"""Dedicated secret wins. Otherwise reuse the chat reply secret."""
|
| 124 |
dedicated = (os.environ.get("ROBINHOOD_BRIDGE_SECRET") or "").strip()
|
|
@@ -264,7 +350,7 @@ app.add_middleware(
|
|
| 264 |
],
|
| 265 |
allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
|
| 266 |
allow_credentials=False,
|
| 267 |
-
allow_methods=["GET", "POST", "OPTIONS"],
|
| 268 |
allow_headers=["Content-Type", "Authorization", "X-Webhook-Key"],
|
| 269 |
max_age=86400,
|
| 270 |
)
|
|
@@ -425,7 +511,7 @@ NO_STORE = {"Cache-Control": "no-store"}
|
|
| 425 |
@app.get("/robinhood")
|
| 426 |
@app.get("/api/robinhood")
|
| 427 |
def get_robinhood() -> JSONResponse:
|
| 428 |
-
return JSONResponse(
|
| 429 |
|
| 430 |
|
| 431 |
@app.post("/robinhood")
|
|
@@ -447,9 +533,46 @@ async def post_robinhood(request: Request, authorization: str | None = Header(de
|
|
| 447 |
raise HTTPException(400, "Invalid Robinhood snapshot.") from exc
|
| 448 |
snapshot = normalize_snapshot(body)
|
| 449 |
save_snapshot(snapshot)
|
|
|
|
| 450 |
return JSONResponse({"ok": True, "updatedAt": snapshot["updatedAt"]}, headers=NO_STORE)
|
| 451 |
|
| 452 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
@app.exception_handler(HTTPException)
|
| 454 |
async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
|
| 455 |
detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail)
|
|
|
|
| 119 |
tmp.replace(path)
|
| 120 |
|
| 121 |
|
| 122 |
+
def refresh_file() -> Path:
|
| 123 |
+
return STORE.parent / "robinhood-refresh.json"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def utc_stamp(moment: datetime | None = None) -> str:
|
| 127 |
+
current = moment or datetime.now(timezone.utc)
|
| 128 |
+
return current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def parse_utc(raw: str | None) -> datetime | None:
|
| 132 |
+
text = (raw or "").strip()
|
| 133 |
+
if not text:
|
| 134 |
+
return None
|
| 135 |
+
try:
|
| 136 |
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
| 137 |
+
except ValueError:
|
| 138 |
+
return None
|
| 139 |
+
if parsed.tzinfo is None:
|
| 140 |
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
| 141 |
+
return parsed.astimezone(timezone.utc)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def load_refresh_request() -> str | None:
|
| 145 |
+
path = refresh_file()
|
| 146 |
+
if not path.is_file():
|
| 147 |
+
return None
|
| 148 |
+
try:
|
| 149 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 150 |
+
except (OSError, json.JSONDecodeError):
|
| 151 |
+
return None
|
| 152 |
+
if not isinstance(data, dict):
|
| 153 |
+
return None
|
| 154 |
+
raw = data.get("requestedAt")
|
| 155 |
+
parsed = parse_utc(raw if isinstance(raw, str) else None)
|
| 156 |
+
if parsed is None:
|
| 157 |
+
return None
|
| 158 |
+
return utc_stamp(parsed)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def save_refresh_request(requested_at: str) -> None:
|
| 162 |
+
path = refresh_file()
|
| 163 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
| 165 |
+
tmp.write_text(json.dumps({"requestedAt": requested_at}), encoding="utf-8")
|
| 166 |
+
tmp.replace(path)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def clear_refresh_request() -> None:
|
| 170 |
+
path = refresh_file()
|
| 171 |
+
path.unlink(missing_ok=True)
|
| 172 |
+
path.with_suffix(path.suffix + ".tmp").unlink(missing_ok=True)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def refresh_is_pending(snapshot: dict[str, Any] | None, requested_at: str | None) -> bool:
|
| 176 |
+
"""True when a request is waiting on a snapshot that is missing or older than it."""
|
| 177 |
+
requested = parse_utc(requested_at)
|
| 178 |
+
if requested is None:
|
| 179 |
+
return False
|
| 180 |
+
if not snapshot:
|
| 181 |
+
return True
|
| 182 |
+
updated = parse_utc(str(snapshot.get("updatedAt") or ""))
|
| 183 |
+
if updated is None:
|
| 184 |
+
return True
|
| 185 |
+
return requested > updated
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def robinhood_payload() -> dict[str, Any]:
|
| 189 |
+
snapshot = load_snapshot()
|
| 190 |
+
requested_at = load_refresh_request()
|
| 191 |
+
pending = refresh_is_pending(snapshot, requested_at)
|
| 192 |
+
body: dict[str, Any] = {"snapshot": snapshot, "refreshPending": pending}
|
| 193 |
+
if pending and requested_at:
|
| 194 |
+
body["refreshRequestedAt"] = requested_at
|
| 195 |
+
return body
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# One public refresh per IP inside this window. Phone retries after the ~45s wait.
|
| 199 |
+
REFRESH_INTERVAL_S = 15.0
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def client_addr(request: Request) -> str:
|
| 203 |
+
forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip()
|
| 204 |
+
host = forwarded or (request.client.host if request.client else "")
|
| 205 |
+
return (host or "unknown")[:80]
|
| 206 |
+
|
| 207 |
+
|
| 208 |
def robinhood_secret() -> str:
|
| 209 |
"""Dedicated secret wins. Otherwise reuse the chat reply secret."""
|
| 210 |
dedicated = (os.environ.get("ROBINHOOD_BRIDGE_SECRET") or "").strip()
|
|
|
|
| 350 |
],
|
| 351 |
allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
|
| 352 |
allow_credentials=False,
|
| 353 |
+
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
| 354 |
allow_headers=["Content-Type", "Authorization", "X-Webhook-Key"],
|
| 355 |
max_age=86400,
|
| 356 |
)
|
|
|
|
| 511 |
@app.get("/robinhood")
|
| 512 |
@app.get("/api/robinhood")
|
| 513 |
def get_robinhood() -> JSONResponse:
|
| 514 |
+
return JSONResponse(robinhood_payload(), headers=NO_STORE)
|
| 515 |
|
| 516 |
|
| 517 |
@app.post("/robinhood")
|
|
|
|
| 533 |
raise HTTPException(400, "Invalid Robinhood snapshot.") from exc
|
| 534 |
snapshot = normalize_snapshot(body)
|
| 535 |
save_snapshot(snapshot)
|
| 536 |
+
clear_refresh_request()
|
| 537 |
return JSONResponse({"ok": True, "updatedAt": snapshot["updatedAt"]}, headers=NO_STORE)
|
| 538 |
|
| 539 |
|
| 540 |
+
@app.post("/robinhood/refresh")
|
| 541 |
+
@app.post("/api/robinhood/refresh")
|
| 542 |
+
def post_robinhood_refresh(request: Request) -> JSONResponse:
|
| 543 |
+
"""Public. Phone asks Chief of Staff to pull Robinhood; it never calls Robinhood itself."""
|
| 544 |
+
if rate_limited(f"robinhood:refresh:{client_addr(request)}", max_n=1, window_s=REFRESH_INTERVAL_S):
|
| 545 |
+
raise HTTPException(429, "A refresh was just requested. Wait a few seconds.")
|
| 546 |
+
requested_at = utc_stamp()
|
| 547 |
+
save_refresh_request(requested_at)
|
| 548 |
+
return JSONResponse(
|
| 549 |
+
{"ok": True, "requestedAt": requested_at, "refreshPending": True},
|
| 550 |
+
headers=NO_STORE,
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
@app.delete("/robinhood/refresh")
|
| 555 |
+
@app.delete("/api/robinhood/refresh")
|
| 556 |
+
def delete_robinhood_refresh(authorization: str | None = Header(default=None)) -> JSONResponse:
|
| 557 |
+
secret = robinhood_secret()
|
| 558 |
+
if not secret:
|
| 559 |
+
raise HTTPException(500, "Space is missing ROBINHOOD_BRIDGE_SECRET (or CHAT_BRIDGE_SECRET).")
|
| 560 |
+
if not bearer_ok(authorization, secret):
|
| 561 |
+
raise HTTPException(401, "Unauthorized.")
|
| 562 |
+
clear_refresh_request()
|
| 563 |
+
return JSONResponse({"ok": True, "refreshPending": False}, headers=NO_STORE)
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
@app.get("/robinhood/pending-refresh")
|
| 567 |
+
@app.get("/api/robinhood/pending-refresh")
|
| 568 |
+
def get_robinhood_pending() -> JSONResponse:
|
| 569 |
+
payload = robinhood_payload()
|
| 570 |
+
body: dict[str, Any] = {"refreshPending": payload["refreshPending"]}
|
| 571 |
+
if "refreshRequestedAt" in payload:
|
| 572 |
+
body["refreshRequestedAt"] = payload["refreshRequestedAt"]
|
| 573 |
+
return JSONResponse(body, headers=NO_STORE)
|
| 574 |
+
|
| 575 |
+
|
| 576 |
@app.exception_handler(HTTPException)
|
| 577 |
async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
|
| 578 |
detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail)
|
test_robinhood.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
import unittest
|
| 3 |
import uuid
|
| 4 |
|
| 5 |
from fastapi.testclient import TestClient
|
| 6 |
|
| 7 |
-
from main import RATE, app, snapshot_file
|
| 8 |
|
| 9 |
|
| 10 |
SAMPLE = {
|
|
@@ -37,29 +38,30 @@ class RobinhoodBridgeTest(unittest.TestCase):
|
|
| 37 |
os.environ.pop("ROBINHOOD_BRIDGE_SECRET", None)
|
| 38 |
os.environ["CHAT_BRIDGE_SECRET"] = "chat-secret-value"
|
| 39 |
RATE.clear()
|
| 40 |
-
|
| 41 |
-
if path.exists():
|
| 42 |
-
path.unlink()
|
| 43 |
-
tmp = path.with_suffix(path.suffix + ".tmp")
|
| 44 |
-
if tmp.exists():
|
| 45 |
-
tmp.unlink()
|
| 46 |
self.client = TestClient(app)
|
| 47 |
|
| 48 |
def tearDown(self) -> None:
|
| 49 |
self.client.close()
|
| 50 |
-
|
| 51 |
-
if path.exists():
|
| 52 |
-
path.unlink()
|
| 53 |
for key, value in self._env.items():
|
| 54 |
if value is None:
|
| 55 |
os.environ.pop(key, None)
|
| 56 |
else:
|
| 57 |
os.environ[key] = value
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def test_get_empty_snapshot_is_public(self) -> None:
|
| 60 |
res = self.client.get("/api/robinhood")
|
| 61 |
self.assertEqual(res.status_code, 200)
|
| 62 |
-
self.assertEqual(res.json(), {"snapshot": None})
|
| 63 |
self.assertEqual(res.headers.get("cache-control"), "no-store")
|
| 64 |
|
| 65 |
def test_post_requires_bearer_and_masks_account(self) -> None:
|
|
@@ -147,6 +149,117 @@ class RobinhoodBridgeTest(unittest.TestCase):
|
|
| 147 |
self.assertEqual(polled.status_code, 200)
|
| 148 |
self.assertEqual(polled.json()["messages"][-1]["text"], "ok")
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
if __name__ == "__main__":
|
| 152 |
unittest.main()
|
|
|
|
| 1 |
+
import json
|
| 2 |
import os
|
| 3 |
import unittest
|
| 4 |
import uuid
|
| 5 |
|
| 6 |
from fastapi.testclient import TestClient
|
| 7 |
|
| 8 |
+
from main import RATE, app, refresh_file, snapshot_file
|
| 9 |
|
| 10 |
|
| 11 |
SAMPLE = {
|
|
|
|
| 38 |
os.environ.pop("ROBINHOOD_BRIDGE_SECRET", None)
|
| 39 |
os.environ["CHAT_BRIDGE_SECRET"] = "chat-secret-value"
|
| 40 |
RATE.clear()
|
| 41 |
+
self._wipe_files()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
self.client = TestClient(app)
|
| 43 |
|
| 44 |
def tearDown(self) -> None:
|
| 45 |
self.client.close()
|
| 46 |
+
self._wipe_files()
|
|
|
|
|
|
|
| 47 |
for key, value in self._env.items():
|
| 48 |
if value is None:
|
| 49 |
os.environ.pop(key, None)
|
| 50 |
else:
|
| 51 |
os.environ[key] = value
|
| 52 |
|
| 53 |
+
def _wipe_files(self) -> None:
|
| 54 |
+
for path in (snapshot_file(), refresh_file()):
|
| 55 |
+
if path.exists():
|
| 56 |
+
path.unlink()
|
| 57 |
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
| 58 |
+
if tmp.exists():
|
| 59 |
+
tmp.unlink()
|
| 60 |
+
|
| 61 |
def test_get_empty_snapshot_is_public(self) -> None:
|
| 62 |
res = self.client.get("/api/robinhood")
|
| 63 |
self.assertEqual(res.status_code, 200)
|
| 64 |
+
self.assertEqual(res.json(), {"snapshot": None, "refreshPending": False})
|
| 65 |
self.assertEqual(res.headers.get("cache-control"), "no-store")
|
| 66 |
|
| 67 |
def test_post_requires_bearer_and_masks_account(self) -> None:
|
|
|
|
| 149 |
self.assertEqual(polled.status_code, 200)
|
| 150 |
self.assertEqual(polled.json()["messages"][-1]["text"], "ok")
|
| 151 |
|
| 152 |
+
def test_refresh_request_is_public_and_rate_limited_per_ip(self) -> None:
|
| 153 |
+
os.environ.pop("ROBINHOOD_BRIDGE_SECRET", None)
|
| 154 |
+
os.environ.pop("CHAT_BRIDGE_SECRET", None)
|
| 155 |
+
first = self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.10"})
|
| 156 |
+
self.assertEqual(first.status_code, 200)
|
| 157 |
+
body = first.json()
|
| 158 |
+
self.assertTrue(body["ok"])
|
| 159 |
+
self.assertTrue(body["refreshPending"])
|
| 160 |
+
self.assertRegex(body["requestedAt"], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
| 161 |
+
self.assertEqual(first.headers.get("cache-control"), "no-store")
|
| 162 |
+
stored = json.loads(refresh_file().read_text(encoding="utf-8"))
|
| 163 |
+
self.assertEqual(stored, {"requestedAt": body["requestedAt"]})
|
| 164 |
+
|
| 165 |
+
again = self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.10"})
|
| 166 |
+
self.assertEqual(again.status_code, 429)
|
| 167 |
+
|
| 168 |
+
other = self.client.post("/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.11"})
|
| 169 |
+
self.assertEqual(other.status_code, 200)
|
| 170 |
+
|
| 171 |
+
def test_refresh_pending_until_snapshot_is_newer(self) -> None:
|
| 172 |
+
headers = {"Authorization": "Bearer chat-secret-value"}
|
| 173 |
+
old = dict(SAMPLE)
|
| 174 |
+
old["updatedAt"] = "2020-01-01T00:00:00Z"
|
| 175 |
+
self.client.post("/api/robinhood", json=old, headers=headers)
|
| 176 |
+
asked = self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.12"})
|
| 177 |
+
requested_at = asked.json()["requestedAt"]
|
| 178 |
+
|
| 179 |
+
got = self.client.get("/api/robinhood")
|
| 180 |
+
body = got.json()
|
| 181 |
+
self.assertEqual(got.headers.get("cache-control"), "no-store")
|
| 182 |
+
self.assertTrue(body["refreshPending"])
|
| 183 |
+
self.assertEqual(body["refreshRequestedAt"], requested_at)
|
| 184 |
+
self.assertEqual(body["snapshot"]["updatedAt"], "2020-01-01T00:00:00Z")
|
| 185 |
+
|
| 186 |
+
pending = self.client.get("/api/robinhood/pending-refresh")
|
| 187 |
+
self.assertEqual(pending.status_code, 200)
|
| 188 |
+
self.assertEqual(
|
| 189 |
+
pending.json(),
|
| 190 |
+
{"refreshPending": True, "refreshRequestedAt": requested_at},
|
| 191 |
+
)
|
| 192 |
+
alias = self.client.get("/robinhood/pending-refresh")
|
| 193 |
+
self.assertEqual(alias.json()["refreshPending"], True)
|
| 194 |
+
|
| 195 |
+
def test_pending_flag_is_false_when_snapshot_is_already_newer(self) -> None:
|
| 196 |
+
headers = {"Authorization": "Bearer chat-secret-value"}
|
| 197 |
+
future = dict(SAMPLE)
|
| 198 |
+
future["updatedAt"] = "2099-01-01T00:00:00Z"
|
| 199 |
+
self.client.post("/api/robinhood", json=future, headers=headers)
|
| 200 |
+
self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.13"})
|
| 201 |
+
body = self.client.get("/api/robinhood").json()
|
| 202 |
+
self.assertFalse(body["refreshPending"])
|
| 203 |
+
self.assertNotIn("refreshRequestedAt", body)
|
| 204 |
+
self.assertTrue(refresh_file().is_file())
|
| 205 |
+
self.assertEqual(self.client.get("/api/robinhood/pending-refresh").json(), {"refreshPending": False})
|
| 206 |
+
|
| 207 |
+
def test_snapshot_post_clears_pending_refresh(self) -> None:
|
| 208 |
+
self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.14"})
|
| 209 |
+
self.assertTrue(refresh_file().is_file())
|
| 210 |
+
denied = self.client.post("/api/robinhood", json=SAMPLE)
|
| 211 |
+
self.assertEqual(denied.status_code, 401)
|
| 212 |
+
self.assertTrue(self.client.get("/api/robinhood").json()["refreshPending"])
|
| 213 |
+
|
| 214 |
+
res = self.client.post(
|
| 215 |
+
"/api/robinhood",
|
| 216 |
+
json=SAMPLE,
|
| 217 |
+
headers={"Authorization": "Bearer chat-secret-value"},
|
| 218 |
+
)
|
| 219 |
+
self.assertEqual(res.status_code, 200)
|
| 220 |
+
self.assertFalse(refresh_file().exists())
|
| 221 |
+
body = self.client.get("/api/robinhood").json()
|
| 222 |
+
self.assertFalse(body["refreshPending"])
|
| 223 |
+
self.assertNotIn("refreshRequestedAt", body)
|
| 224 |
+
self.assertEqual(self.client.get("/robinhood/pending-refresh").json(), {"refreshPending": False})
|
| 225 |
+
|
| 226 |
+
def test_delete_refresh_requires_snapshot_bearer(self) -> None:
|
| 227 |
+
self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.15"})
|
| 228 |
+
denied = self.client.delete("/api/robinhood/refresh")
|
| 229 |
+
self.assertEqual(denied.status_code, 401)
|
| 230 |
+
self.assertTrue(refresh_file().is_file())
|
| 231 |
+
|
| 232 |
+
os.environ["ROBINHOOD_BRIDGE_SECRET"] = "rh-secret-value"
|
| 233 |
+
chat = self.client.delete(
|
| 234 |
+
"/api/robinhood/refresh",
|
| 235 |
+
headers={"Authorization": "Bearer chat-secret-value"},
|
| 236 |
+
)
|
| 237 |
+
self.assertEqual(chat.status_code, 401)
|
| 238 |
+
self.assertTrue(refresh_file().is_file())
|
| 239 |
+
|
| 240 |
+
ok = self.client.delete(
|
| 241 |
+
"/robinhood/refresh",
|
| 242 |
+
headers={"Authorization": "Bearer rh-secret-value"},
|
| 243 |
+
)
|
| 244 |
+
self.assertEqual(ok.status_code, 200)
|
| 245 |
+
self.assertEqual(ok.json(), {"ok": True, "refreshPending": False})
|
| 246 |
+
self.assertFalse(refresh_file().exists())
|
| 247 |
+
self.assertFalse(self.client.get("/api/robinhood").json()["refreshPending"])
|
| 248 |
+
|
| 249 |
+
def test_refresh_cors_allows_phone_origin(self) -> None:
|
| 250 |
+
res = self.client.options(
|
| 251 |
+
"/api/robinhood/refresh",
|
| 252 |
+
headers={
|
| 253 |
+
"Origin": "https://simzy420.github.io",
|
| 254 |
+
"Access-Control-Request-Method": "POST",
|
| 255 |
+
},
|
| 256 |
+
)
|
| 257 |
+
self.assertIn(res.status_code, (200, 204))
|
| 258 |
+
self.assertEqual(res.headers.get("access-control-allow-origin"), "https://simzy420.github.io")
|
| 259 |
+
allow = res.headers.get("access-control-allow-methods", "")
|
| 260 |
+
self.assertIn("POST", allow)
|
| 261 |
+
self.assertIn("DELETE", allow)
|
| 262 |
+
|
| 263 |
|
| 264 |
if __name__ == "__main__":
|
| 265 |
unittest.main()
|