diff --git a/app/api/captures.py b/app/api/captures.py
new file mode 100644
index 0000000..158a2dd
--- /dev/null
+++ b/app/api/captures.py
@@ -0,0 +1,59 @@
+import json
+import os
+import re
+import secrets
+import tempfile
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+CAPTURE_DIR = Path(tempfile.gettempdir()) / "chart-captures"
+CAPTURE_TTL_SECONDS = 24 * 60 * 60
+CAPTURE_LIMIT = 50
+CAPTURE_MAX_BYTES = 10 * 1024 * 1024
+CAPTURE_ID = re.compile(r"^c-[A-Za-z0-9_-]{12}$")
+
+
+def _remove(capture_id: str) -> None:
+ for suffix in (".png", ".json"):
+ (CAPTURE_DIR / f"{capture_id}{suffix}").unlink(missing_ok=True)
+
+
+def cleanup_captures(now: float | None = None) -> None:
+ current = time.time() if now is None else now
+ images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
+ for image in images:
+ if current - image.stat().st_mtime > CAPTURE_TTL_SECONDS:
+ _remove(image.stem)
+ images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
+ for image in images[: max(0, len(images) - CAPTURE_LIMIT + 1)]:
+ _remove(image.stem)
+
+
+def save_capture(image: bytes, metadata: dict) -> tuple[str, dict]:
+ if not image.startswith(b"\x89PNG\r\n\x1a\n"):
+ raise ValueError("Capture is not a PNG")
+ if len(image) > CAPTURE_MAX_BYTES:
+ raise ValueError("Capture exceeds 10 MB")
+ CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
+ cleanup_captures()
+ capture_id = f"c-{secrets.token_urlsafe(9)}"
+ details = {
+ **metadata,
+ "id": capture_id,
+ "received_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
+ "commit": os.environ.get("SOURCE_COMMIT", "dev"),
+ "bytes": len(image),
+ }
+ image_path = CAPTURE_DIR / f"{capture_id}.png"
+ metadata_path = CAPTURE_DIR / f"{capture_id}.json"
+ image_path.write_bytes(image)
+ metadata_path.write_text(json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ return capture_id, details
+
+
+def capture_path(capture_id: str, suffix: str) -> Path | None:
+ if not CAPTURE_ID.fullmatch(capture_id):
+ return None
+ path = CAPTURE_DIR / f"{capture_id}{suffix}"
+ return path if path.is_file() else None
diff --git a/app/api/meta.py b/app/api/meta.py
index 424c9b3..12906df 100644
--- a/app/api/meta.py
+++ b/app/api/meta.py
@@ -5,9 +5,11 @@ the browser needs /api/login before it has a session, so these routes stay
outside the protected API router.
"""
import os
+import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, Response, status
+from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.api.deps import (
@@ -18,6 +20,7 @@ from app.api.deps import (
password_matches,
token_matches,
)
+from app.api.captures import capture_path
router = APIRouter(prefix="/api")
@@ -40,6 +43,22 @@ def version():
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
+@router.get("/debug/captures/{capture_id}")
+def get_debug_capture(capture_id: str):
+ path = capture_path(capture_id, ".png")
+ if path is None:
+ raise HTTPException(404, "Capture not found")
+ return FileResponse(path, media_type="image/png")
+
+
+@router.get("/debug/captures/{capture_id}/meta")
+def get_debug_capture_metadata(capture_id: str):
+ path = capture_path(capture_id, ".json")
+ if path is None:
+ raise HTTPException(404, "Capture not found")
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
def login(credentials: LoginRequest, request: Request, response: Response):
presented_token = request.headers.get("x-chart-token", "")
diff --git a/app/api/routes.py b/app/api/routes.py
index 2298201..389d73a 100644
--- a/app/api/routes.py
+++ b/app/api/routes.py
@@ -1,3 +1,5 @@
+import base64
+import json
import logging
import time
import uuid
@@ -9,6 +11,7 @@ from app.bars.models import Timeframe
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
from app.api.deps import require_token
+from app.api.captures import CAPTURE_MAX_BYTES, save_capture
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
# /version live in app.api.meta and stay open on purpose.
@@ -17,6 +20,42 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
+@router.post("/debug/captures", status_code=201)
+async def create_debug_capture(request: Request):
+ try:
+ content_length = int(request.headers.get("content-length", "0") or 0)
+ except ValueError:
+ raise HTTPException(400, "Invalid Content-Length") from None
+ if content_length > CAPTURE_MAX_BYTES:
+ raise HTTPException(413, "Capture exceeds 10 MB")
+ if request.headers.get("content-type", "").split(";", 1)[0] != "image/png":
+ raise HTTPException(415, "Diagnostic capture must be image/png")
+ try:
+ encoded = request.headers.get("x-capture-metadata", "")
+ metadata = json.loads(base64.b64decode(encoded, validate=True)) if encoded else {}
+ if not isinstance(metadata, dict):
+ raise ValueError
+ except (ValueError, json.JSONDecodeError):
+ raise HTTPException(400, "Invalid capture metadata") from None
+ image = await request.body()
+ try:
+ capture_id, details = save_capture(image, metadata)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ logger.warning(
+ "CAPTUREDBG id=%s tf=%s viewport=%sx%s",
+ capture_id,
+ details.get("timeframe"),
+ details.get("viewport_width"),
+ details.get("viewport_height"),
+ )
+ return {
+ "id": capture_id,
+ "url": f"/api/debug/captures/{capture_id}",
+ "metadata_url": f"/api/debug/captures/{capture_id}/meta",
+ }
+
+
class LineCreate(BaseModel):
tf: Timeframe
side: Side
diff --git a/static/app.js b/static/app.js
index 1f3a092..884dbed 100644
--- a/static/app.js
+++ b/static/app.js
@@ -110,8 +110,8 @@ createApp({
const timeframe = ref(prefs.value.base_tf || '1m');
const levels = ref([]);
const clusters = ref([]);
- const alerts = ref([]);
- let alertSequence = 0;
+ const events = ref([]);
+ let eventSequence = 0;
// Which tool the next chart gesture creates. null = pan/select as normal.
const armedTool = ref(null);
const drawName = ref('');
@@ -125,6 +125,8 @@ createApp({
const comments = ref([]);
const drawingFilter = ref('');
const drawingKind = ref('all');
+ const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
+ const captureBusy = ref(false);
const snap = ref(true);
const selectedLine = ref(null);
const selectedLines = ref([]);
@@ -191,6 +193,13 @@ createApp({
});
});
+ function addEvent(message, kind = 'info', url = null) {
+ events.value = [
+ { key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url },
+ ...events.value,
+ ].slice(0, 20);
+ }
+
async function refreshStatus() {
const response = await apiFetch('/api/status');
if (response.ok) status.value = await response.json();
@@ -238,10 +247,7 @@ createApp({
} else if (message.type === 'alert') {
// Keyed on a counter, not the timestamp: two alerts inside the same
// second would collide and Vue would reuse the wrong row.
- alerts.value = [
- { key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message },
- ...alerts.value,
- ].slice(0, 20);
+ addEvent(message.message, 'alert');
playAlert();
}
};
@@ -281,6 +287,82 @@ createApp({
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
}
+ function encodeCaptureMetadata(metadata) {
+ const bytes = new TextEncoder().encode(JSON.stringify(metadata));
+ return btoa([...bytes].map(byte => String.fromCharCode(byte)).join(''));
+ }
+
+ async function captureDiagnostic() {
+ if (captureBusy.value) return;
+ if (!navigator.mediaDevices?.getDisplayMedia) {
+ addEvent('CAPTURE UNAVAILABLE · use HTTPS and a supported browser', 'capture-error');
+ return;
+ }
+ captureBusy.value = true;
+ let stream = null;
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({
+ video: { displaySurface: 'browser' },
+ audio: false,
+ preferCurrentTab: true,
+ selfBrowserSurface: 'include',
+ });
+ const video = document.createElement('video');
+ video.muted = true;
+ await new Promise((resolve, reject) => {
+ video.onloadedmetadata = resolve;
+ video.onerror = reject;
+ video.srcObject = stream;
+ });
+ await video.play();
+ await new Promise(resolve => {
+ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(() => resolve());
+ else requestAnimationFrame(() => resolve());
+ });
+ const canvas = document.createElement('canvas');
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ canvas.getContext('2d').drawImage(video, 0, 0);
+ const image = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
+ if (!image) throw new Error('Browser did not produce a PNG');
+ const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
+ const bars = chartApi?.bars || [];
+ const selected = levels.value.find(level => level.id === selectedLine.value) || null;
+ const metadata = {
+ url: location.href,
+ timeframe: timeframe.value,
+ viewport_width: window.innerWidth,
+ viewport_height: window.innerHeight,
+ device_pixel_ratio: window.devicePixelRatio,
+ image_width: canvas.width,
+ image_height: canvas.height,
+ first_bar_t: bars[0]?.t ?? null,
+ last_bar_t: bars[bars.length - 1]?.t ?? null,
+ bars_held: bars.length,
+ selected_drawing: selected,
+ latest_snap: chartApi?.lastSnapDiagnostic ?? null,
+ commit: version.commit ?? null,
+ };
+ const response = await apiFetch('/api/debug/captures', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'image/png',
+ 'X-Capture-Metadata': encodeCaptureMetadata(metadata),
+ },
+ body: image,
+ });
+ if (!response.ok) throw new Error(`upload returned HTTP ${response.status}`);
+ const saved = await response.json();
+ addEvent(`CAPTURE ${saved.id} · ${timeframe.value}`, 'capture', saved.url);
+ } catch (error) {
+ const cancelled = error?.name === 'AbortError' || error?.name === 'NotAllowedError';
+ addEvent(cancelled ? 'CAPTURE CANCELLED' : `CAPTURE FAILED · ${error.message}`, 'capture-error');
+ } finally {
+ stream?.getTracks().forEach(track => track.stop());
+ captureBusy.value = false;
+ }
+ }
+
function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool;
selectedLine.value = null;
@@ -677,6 +759,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
- return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
+ return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
},
}).mount('#app');
diff --git a/static/chart.js b/static/chart.js
index 672b7e3..599a4a3 100644
--- a/static/chart.js
+++ b/static/chart.js
@@ -56,6 +56,7 @@ class ConfluenceChart {
this.contextLabelFrame = null;
this.onCommentToggle = null;
this.onCommentMove = null;
+ this.lastSnapDiagnostic = null;
}
static TICK = 0.25;
@@ -899,6 +900,7 @@ class ConfluenceChart {
? 'coordinateToTime returned null'
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
};
+ this.lastSnapDiagnostic = body;
fetch('/api/debug/snap', {
method: 'POST',
headers: { 'Content-Type': 'application/json',
diff --git a/static/index.html b/static/index.html
index 0a55308..61c2408 100644
--- a/static/index.html
+++ b/static/index.html
@@ -32,6 +32,9 @@
Drag on the chart from one point to the other
Click or drag on the chart to set the price
Line selected — Delete removes it
+
UPDATED {{ dataUpdatedAt }}
@@ -224,9 +227,13 @@
diff --git a/static/style.css b/static/style.css
index da4c210..d27800c 100644
--- a/static/style.css
+++ b/static/style.css
@@ -15,7 +15,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
-.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }
+.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }
@@ -53,7 +53,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); }
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
-.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
+.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
diff --git a/tests/e2e/chart.test.mjs b/tests/e2e/chart.test.mjs
index 49d6f8f..fc44788 100644
--- a/tests/e2e/chart.test.mjs
+++ b/tests/e2e/chart.test.mjs
@@ -71,6 +71,71 @@ test('an existing browser token is migrated once and removed from local storage'
}
});
+test('diagnostic capture uploads a PNG and adds its capability ID to Events',
+ { timeout: 180000 }, async () => {
+ const { browser, page } = await launch();
+ let uploaded = null;
+ try {
+ await page.addInitScript(() => localStorage.setItem('chart-diag', '1'));
+ await page.route('**/api/debug/captures', async route => {
+ uploaded = {
+ contentType: route.request().headers()['content-type'],
+ metadata: JSON.parse(Buffer.from(
+ route.request().headers()['x-capture-metadata'], 'base64').toString()),
+ };
+ await route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ id: 'c-TESTCAPTURE1',
+ url: '/api/debug/captures/c-TESTCAPTURE1',
+ }),
+ });
+ });
+ await openChart(page);
+ await page.evaluate(() => {
+ Object.defineProperty(navigator, 'mediaDevices', {
+ configurable: true,
+ value: { getDisplayMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
+ });
+ Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
+ configurable: true,
+ set() { setTimeout(() => this.onloadedmetadata?.(), 0); },
+ });
+ Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', {
+ configurable: true, get: () => 800,
+ });
+ Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', {
+ configurable: true, get: () => 600,
+ });
+ HTMLMediaElement.prototype.play = async function play() {};
+ HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); };
+ HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} });
+ HTMLCanvasElement.prototype.toBlob = function toBlob(done) {
+ done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], {
+ type: 'image/png',
+ }));
+ };
+ });
+
+ await page.locator('.diag-capture').click();
+ await page.waitForFunction(() =>
+ [...document.querySelectorAll('.alert-entry')]
+ .some(entry => entry.textContent.includes('c-TESTCAPTURE1')));
+
+ assert.equal(uploaded.contentType, 'image/png');
+ assert.equal(uploaded.metadata.timeframe, '1m');
+ assert.equal(uploaded.metadata.image_width, 800);
+ assert.equal(uploaded.metadata.image_height, 600);
+ const event = page.locator('.alert-entry.capture a');
+ assert.match(await event.textContent(), /CAPTURE c-TESTCAPTURE1 · 1m/);
+ assert.match(await event.getAttribute('href'), /\/api\/debug\/captures\/c-TESTCAPTURE1$/);
+ assertNoPageErrors(page, assert);
+ } finally {
+ await browser.close();
+ }
+ });
+
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
await withChart(async page => {
const state = await chartState(page);
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 3b22971..ee59252 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,3 +1,6 @@
+import base64
+import json
+
import pytest
import jwt
from fastapi import FastAPI
@@ -5,6 +8,7 @@ from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.api.meta import router as meta_router
+from app.api import captures
from app.api.deps import create_session, session_matches, session_secret
from app.api.routes import router as api_router
from app.api.schwab_auth import router as schwab_auth_router
@@ -170,6 +174,41 @@ def test_writes_are_protected(client):
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
+def test_diagnostic_capture_is_authenticated_and_retrievable_by_capability_id(
+ client, tmp_path, monkeypatch
+):
+ monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
+ probe = client("s3cret")
+ image = b"\x89PNG\r\n\x1a\ntrimmed-test-image"
+ metadata = base64.b64encode(
+ json.dumps({"timeframe": "30m", "viewport_width": 1440}).encode()
+ ).decode()
+
+ assert probe.post(
+ "/api/debug/captures",
+ content=image,
+ headers={"Content-Type": "image/png", "X-Capture-Metadata": metadata},
+ ).status_code == 401
+ response = probe.post(
+ "/api/debug/captures",
+ content=image,
+ headers={
+ "X-Chart-Token": "s3cret",
+ "Content-Type": "image/png",
+ "X-Capture-Metadata": metadata,
+ },
+ )
+
+ assert response.status_code == 201
+ saved = response.json()
+ assert saved["id"].startswith("c-")
+ assert probe.get(saved["url"]).content == image
+ details = probe.get(saved["metadata_url"]).json()
+ assert details["id"] == saved["id"]
+ assert details["timeframe"] == "30m"
+ assert details["viewport_width"] == 1440
+
+
@pytest.mark.parametrize("path", ["/api/health", "/api/version"])
def test_meta_endpoints_stay_open(client, path):
"""bin/wait-deploy polls /api/version without carrying the token."""