diff --git a/openpilot/starpilot/galaxy/sentry_events.py b/openpilot/starpilot/galaxy/sentry_events.py new file mode 100644 index 0000000000..4fe0e0b325 --- /dev/null +++ b/openpilot/starpilot/galaxy/sentry_events.py @@ -0,0 +1,68 @@ +"""Bounded projection of local Sentry motion metadata.""" + +import json +import re +from collections.abc import Callable +from typing import Protocol, cast + +from openpilot.starpilot.sentry_mode.storage import EventStore, StorageUnavailable + + +MAX_RESPONSE_BYTES = 128 * 1024 +ID = re.compile(r"[0-9a-f]{32}\Z") + + +class SentryEventsUnavailable(Exception): + pass + + +class EventSnapshotSource(Protocol): + def snapshot(self) -> dict: ... + + +class EventDeletionSource(Protocol): + def delete(self, event_id: str | None = None, *, permitted: Callable[[], bool]) -> dict: ... + + +class SentryEvents: + def __init__(self, store: EventSnapshotSource | None = None): + self.store = store if store is not None else EventStore() + + def snapshot(self) -> dict: + try: + source = self.store.snapshot() + if (type(source) is not dict or type(source.get("version")) is not int or source["version"] != 1 or + type(source.get("incomplete")) is not bool or type(source.get("capacity")) is not int or source["capacity"] != 512 or + type(source.get("events")) is not list or len(source["events"]) > 512): + raise ValueError("Invalid motion inventory") + events = [] + for value in source["events"]: + if (type(value) is not dict or type(value.get("eventId")) is not str or + ID.fullmatch(value["eventId"]) is None or type(value.get("kind")) is not str or + value["kind"] not in ("warning", "alarm", "selfie") or + type(value.get("wallTimeNs")) is not int or not 0 < value["wallTimeNs"] < 2**63): + raise ValueError("Invalid motion event") + events.append({"eventId": value["eventId"], "kind": value["kind"], + "systemTimeMs": value["wallTimeNs"] // 1_000_000, + "images": cast(Callable[[str], list], self.store.images)(value["eventId"]) if hasattr(self.store, "images") else []}) + result = {"schemaVersion": 1, "source": "local", "scanIncomplete": source["incomplete"], + "capacity": source["capacity"], "events": events, + "storageBytes": source.get("storageBytes", 0), "storageIncomplete": source.get("storageIncomplete", False)} + if len(json.dumps(result, separators=(",", ":")).encode()) > MAX_RESPONSE_BYTES: + raise ValueError("Motion response exceeds limit") + return result + except (StorageUnavailable, OSError, ValueError, TypeError, OverflowError, RecursionError) as error: + raise SentryEventsUnavailable from error + + def image(self, event_id, camera): + try: + return self.store.image(event_id, camera) + except (OSError, StorageUnavailable, AttributeError) as error: + raise SentryEventsUnavailable from error + + def delete(self, event_id=None, *, permitted=lambda: True): + try: + result = cast(EventDeletionSource, self.store).delete(event_id, permitted=permitted) + return {**self.snapshot(), **result} + except (StorageUnavailable, OSError, AttributeError) as error: + raise SentryEventsUnavailable from error diff --git a/openpilot/starpilot/galaxy/tests/test_sentry.mjs b/openpilot/starpilot/galaxy/tests/test_sentry.mjs new file mode 100644 index 0000000000..09469b8979 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_sentry.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { SentryEventsFeed, SentryEventsPage, validSentryEvents } from '../web/js/sentry.js' +import { CamerasPage } from '../web/js/cameras.js' + +const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() } +const eventId = 'a'.repeat(32) +const inventory = { schemaVersion: 1, source: 'local', scanIncomplete: false, capacity: 512, + events: [{ eventId, kind: 'warning', systemTimeMs: 1_700_000_000_000 }] } +assert.equal(validSentryEvents(inventory), true) +assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], images: ['wide', 'cabin'] }] }), true) +assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], images: ['../../secret'] }] }), false) +assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], kind: 'armed' }] }), false) +assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], path: '/secret' }] }), false) +assert.equal(validSentryEvents({ ...inventory, events: [inventory.events[0], inventory.events[0]] }), false) +assert.equal(validSentryEvents({ ...inventory, source: 'remote' }), false) +assert.match(SentryEventsPage.template, /device clock and may be inaccurate/) +assert.match(SentryEventsPage.template, /]*event.images[^>]*api\/sentry\/image\//) +assert.doesNotMatch(SentryEventsPage.template, /v-html|deleteEvent|Download/) +assert.match(CamerasPage.template, /View motion events/) +const app = readFileSync(new URL('../web/js/app.js', import.meta.url), 'utf8') +assert.match(app, /SentryEventsPage[^>]*\/cameras\/events[^>]*\/cameras\/sentry-settings/) + +function fixture() { + const requests = [], states = [], timers = new Map() + let next = 0, revoked = 0 + const feed = new SentryEventsFeed({ publish: (state) => states.push(state), unauthorized: () => revoked++, + fetcher: (url, options) => new Promise((resolve) => requests.push({ url, options, resolve })), + later: (fn, ms) => { timers.set(++next, { fn, ms }); return next }, cancelTimer: (id) => timers.delete(id) }) + async function reply(index, body, code = 200) { + requests[index].resolve({ status: code, ok: code === 200, json: async () => body }) + await flush() + } + return { feed, requests, states, timers, reply, get revoked() { return revoked } } +} +const normal = fixture() +normal.feed.start() +assert.equal(normal.requests[0].url, './api/sentry/events') +assert.equal(normal.requests[0].options.credentials, 'same-origin') +await normal.reply(0, inventory) +assert.equal(normal.feed.status, 'ready') +await flush() +assert.equal(normal.requests.length, 1, 'no repeated storage scan without manual refresh') +normal.feed.load() +await normal.reply(1, { ...inventory, events: [] }) +assert.equal(normal.feed.data.events.length, 0) +normal.feed.stop() +assert.equal(normal.feed.data, null) + +const auth = fixture() +auth.feed.start() +await auth.reply(0, { code: 'setup_required' }, 503) +assert.equal(auth.revoked, 1) +assert.equal(auth.feed.data, null) +const loggedOut = fixture() +loggedOut.feed.start() +await loggedOut.reply(0, {}, 401) +assert.equal(loggedOut.revoked, 1) +const unavailable = fixture() +unavailable.feed.start() +await unavailable.reply(0, { code: 'backend_unavailable' }, 503) +assert.equal(unavailable.revoked, 0) +assert.equal(unavailable.feed.status, 'unavailable') + +const timedOut = fixture() +timedOut.feed.start() +assert.deepEqual([...timedOut.timers.values()].map((timer) => timer.ms), [10000, 4000]) +const [timeoutId, timeout] = [...timedOut.timers.entries()].find(([, timer]) => timer.ms === 4000) +timedOut.timers.delete(timeoutId) +timeout.fn() +assert.equal(timedOut.feed.status, 'unavailable') +timedOut.feed.load() +assert.equal(timedOut.requests.length, 2) +await timedOut.reply(1, inventory) +await timedOut.reply(0, { ...inventory, events: [] }) +assert.equal(timedOut.feed.data.events.length, 1) + +const lateBody = fixture() +lateBody.feed.start() +let resolveBody +lateBody.requests[0].resolve({ status: 200, ok: true, json: () => new Promise((resolve) => { resolveBody = resolve }) }) +await flush() +lateBody.feed.stop() +lateBody.feed.start() +await lateBody.reply(1, inventory) +resolveBody({ ...inventory, events: [] }) +await flush() +assert.equal(lateBody.feed.data.events.length, 1) + +const priorDocument = globalThis.document +const listeners = new Map() +globalThis.document = { hidden: true, addEventListener: (name, cb) => listeners.set(name, cb), + removeEventListener: (name, cb) => { if (listeners.get(name) === cb) listeners.delete(name) } } +const page = { mode: 'local', $data: { status: 'idle', data: null, error: '' }, unauthorized() { throw new Error('revoked') } } +SentryEventsPage.created.call(page) +const pending = [] +page.feed.fetcher = (url, options) => new Promise((resolve) => pending.push({ url, options, resolve })) +SentryEventsPage.mounted.call(page) +assert.equal(pending.length, 0) +document.hidden = false +listeners.get('visibilitychange')() +assert.equal(pending.length, 1) +document.hidden = true +listeners.get('visibilitychange')() +assert.equal(page.$data.data, null) +SentryEventsPage.beforeUnmount.call(page) +assert.equal(listeners.size, 0) +globalThis.document = priorDocument diff --git a/openpilot/starpilot/galaxy/tests/test_sentry_events.py b/openpilot/starpilot/galaxy/tests/test_sentry_events.py new file mode 100644 index 0000000000..e709aa10d6 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_sentry_events.py @@ -0,0 +1,59 @@ +"""Local motion records are projected without exposing storage or session details.""" + +from pathlib import Path +import tempfile +import unittest + +from openpilot.starpilot.galaxy.sentry_events import SentryEvents, SentryEventsUnavailable +from openpilot.starpilot.sentry_mode.storage import EventStore + + +class SentryEventsTest(unittest.TestCase): + def test_actual_store_projection_and_empty_store(self): + with tempfile.TemporaryDirectory() as directory: + store = EventStore(Path(directory).resolve() / "events") + self.assertEqual(SentryEvents(store).snapshot()["events"], []) + receipt = store.record("warning", 123, permitted=lambda: True) + result = SentryEvents(store).snapshot() + self.assertEqual(result["schemaVersion"], 1) + self.assertEqual(result["source"], "local") + self.assertEqual(result["capacity"], 512) + self.assertEqual(result["events"][0]["eventId"], receipt.event_id) + self.assertEqual(set(result["events"][0]), {"eventId", "kind", "systemTimeMs", "images"}) + self.assertEqual(result["events"][0]["kind"], "warning") + self.assertEqual(result["events"][0]["images"], []) + + def test_published_images_project_after_restart_and_use_same_event_id(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() / "events" + image = b"\xff\xd8fixture\xff\xd9" + receipt = EventStore(root).record("alarm", 456, permitted=lambda: True, images={"cabin": image}) + projection = SentryEvents(EventStore(root)) + event = projection.snapshot()["events"][0] + self.assertEqual(event["eventId"], receipt.event_id) + self.assertEqual(event["images"], ["cabin"]) + self.assertEqual(projection.image(event["eventId"], "cabin"), image) + with self.assertRaises(SentryEventsUnavailable): + projection.image(event["eventId"], "wide") + + def test_invalid_or_oversized_snapshot_is_unavailable(self): + class Fixture: + def __init__(self, value): + self.value = value + + def snapshot(self): + return self.value + + base = {"version": 1, "incomplete": False, "capacity": 512, "events": []} + event = {"eventId": "a" * 32, "kind": "alarm", "wallTimeNs": 123_000_000} + for value in ({**base, "version": True}, {**base, "capacity": True}, + {**base, "events": [event] * 513}, + {**base, "events": [{**event, "kind": "unknown"}]}, + {**base, "events": [{**event, "wallTimeNs": -1}]}): + with self.subTest(value=value): + with self.assertRaises(SentryEventsUnavailable): + SentryEvents(Fixture(value)).snapshot() + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/starpilot/galaxy/tests/test_sentry_notifications.mjs b/openpilot/starpilot/galaxy/tests/test_sentry_notifications.mjs new file mode 100644 index 0000000000..7955d6a20f --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_sentry_notifications.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { NotificationClient, SentryNotifications, validNotifications, channels } from '../web/js/sentry-notifications.js' +const fixture = () => ({ schemaVersion: 1, queueFull: false, subscriptionCount: 0, subscriptions: [], + deliverySemantics: 'Retries may repeat delivery after an interrupted remote response.', + channels: Object.fromEntries(channels.map(name => [name, { enabled: false, configured: false, pending: 0, lastState: 'idle', lastError: '' }])) }) +const status = fixture() +assert.equal(validNotifications(status), true) +assert.equal(validNotifications({ ...status, subscriptionCount: 17 }), false) +assert.equal(validNotifications({ ...status, channels: { ...status.channels, ntfy: { ...status.channels.ntfy, lastError: 'secret-url' } } }), false) +assert.match(SentryNotifications.template, /Send test/) +assert.match(SentryNotifications.template, /Forget configuration/) +assert.match(SentryNotifications.template, /type="password"/) +assert.doesNotMatch(SentryNotifications.template, /v-html/) +const requests = [], states = []; let revoked = 0 +const client = new NotificationClient({ publish: value => states.push(value), unauthorized: () => revoked++, + fetcher: (url, options) => new Promise(resolve => requests.push({ url, options, resolve })) }) +const first = client.run({ action: 'test', channel: 'ntfy' }) +assert.equal(requests[0].options.credentials, 'same-origin') +assert.equal(JSON.parse(requests[0].options.body).channel, 'ntfy') +requests[0].resolve({ status: 200, ok: true, json: async () => status }) +assert.equal(await first, true) +const pollStart = states.length +const stale = client.run() +assert.equal(states.slice(pollStart).some(state => state.notificationBusy === true), false, "passive refresh keeps buttons stable") +client.stop() +requests[1].resolve({ status: 200, ok: true, json: async () => status }) +assert.equal(await stale, false) +const unauthorized = client.run() +requests[2].resolve({ status: 401, ok: false, json: async () => ({}) }) +assert.equal(await unauthorized, false) +assert.equal(revoked, 1) +const invalid = client.run() +requests[3].resolve({ status: 200, ok: true, json: async () => ({ privateToken: 'bad' }) }) +assert.equal(await invalid, false) +assert.match(states.findLast(state => state.notificationError)?.notificationError, /unavailable/) +const worker = readFileSync(new URL('../web/sentry-push-worker.js', import.meta.url), 'utf8') +assert.match(worker, /\.\/\#\/cameras\/events/) +assert.match(worker, /tag: id/) +// Push stays registered, but stale offline app code must no longer intercept navigation. +assert.doesNotMatch(worker, /addEventListener\("fetch"/) +assert.match(worker, /galaxy-offline-v1/) +const handlers = new Map(), removed = [] +let claims = 0, activation +runInNewContext(worker, { + self: { addEventListener: (name, fn) => handlers.set(name, fn), + clients: { claim: async () => { claims++ } }, skipWaiting() {} }, + caches: { delete: async name => { removed.push(name); return true } }, +}) +handlers.get('activate')({ waitUntil: promise => { activation = promise } }) +await activation +assert.deepEqual(removed, ['galaxy-offline-v1']) +assert.equal(claims, 1) +assert.equal(handlers.has('fetch'), false) +assert.equal(handlers.has('push'), true) +assert.equal(handlers.has('notificationclick'), true) +console.log('Sentry notification browser client and worker checks passed')