diff --git a/.gitignore b/.gitignore index 7446aae..7d62490 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build-release/ .DS_Store *.xcuserstate xcuserdata/ +FuelBoardTests/.build/ diff --git a/APPSTORE.md b/APPSTORE.md new file mode 100644 index 0000000..086bd84 --- /dev/null +++ b/APPSTORE.md @@ -0,0 +1,86 @@ +# FuelBoard App Store Approval Review + +Review date: 2026-08-11 · Target: App Store submission readiness. +This is the companion to SECURITY.md — focuses on App Store *review* gate +(guideline compliance), not code security. + +## Status: NOT SUBMISSION-READY + +Two blockers remain before a real submission (see below). Everything else +checks out for review. + +## Blockers + +### 1. Data source — LAN relay (BLOCKER) +- The app fetches from `http://192.168.1.131:8788` — a private LAN host. Off + the user's network it fails and falls back to sample data. Reviewers on + Apple's network will see empty/sample results → 2.1 (performance) rejection, + and hardcoded IPs look like test builds. +- **Fix**: the govuk API switch (BACKLOG P0). App must talk to a public HTTPS + endpoint before submission. The relay (HTTPS domain) or direct gov.uk OAuth + both work; the code path is already stubbed. + +### 2. Always-location justification (BLOCKER without review notes) +- `NSLocationAlwaysAndWhenInUseUsageDescription` + `UIBackgroundModes: location` + present, but guideline 5.1.1 requires the feature to be obvious AND the app + must visibly use background location. The geofenced "cheapest station" alerts + satisfy this, but the reviewer must see it: + - Provide detailed App Review notes explaining the alert flow (enable Alerts → + geofence trigger → notification when approaching the cheapest station). + - Ensure the Alerts tab is discoverable and functional in review. + - Also: "Always" prompt should only appear after the user opts into alerts + (in-progress UX; currently the permission prompt may fire too early). + +## Review-relevant configuration — PASS + +- [x] **App icon**: was MISSING entirely (invalid-binary rejection). Fixed — + 1024×1024 `AppIcon.appiconset` + `ASSETCATALOG_COMPILER_APPICON_NAME`. +- [x] **Launch screen**: `UILaunchScreen` empty dict present (iOS 14+ style). +- [x] **Device family**: iPhone-only (`TARGETED_DEVICE_FAMILY = 1`) — no iPad + multitasking requirements. +- [x] **Versioning**: `CFBundleShortVersionString 1.0`, `CFBundleVersion 1` + (bump per release). +- [x] **Permissions**: only location (WhenInUse + Always), with clear usage + strings. No contacts/photos/mic/camera. +- [x] **No third-party SDKs** → no SDK privacy manifests or third-party + attribution required. +- [x] **No account creation / login** → no account-deletion requirement (5.1.1(v)). +- [x] **ATS**: no `NSAllowsArbitraryLoads` (raw-IP cleartext is ATS-exempt; once + the relay is HTTPS this is moot). +- [x] **Export compliance**: no crypto beyond keychain/SecItem → no + documentation needed. + +## Content / IP review + +- **Brand logos (Shell, BP, Tesco…)**: bundled from Simple Icons (CC0) + + Wikimedia Commons. Nominative use (identifying stations) is standard practice + (cf. GasBuddy). Low risk, but keep the sources documented. +- **Price data**: gov.uk Fuel Finder data is OGL-licensed — include attribution + in the app (About/credits) once live data ships. Sample fallback data must be + clearly demo-labelled (or removed for review) so reviewers don't think the + prices are fake — 2.3.1 accuracy risk. +- **"Cheapest" claims**: data is 12h-old cache. The UI says "cheapest" — fine, + but a small "prices from the UK Fuel Finder dataset, updated twice daily" + footnote is good hygiene for 2.3.1. + +## Suggested review notes (copy-paste for App Store Connect) + +> FuelBoard shows the cheapest petrol, diesel, and premium fuel near the user +> using the UK government Fuel Finder dataset (OGL). Prices are fetched up to +> twice a day; the app filters 5/10/15-mile radii on-device. Location is used +> to (a) rank stations by distance and (b) power the optional Alerts feature: +> when enabled, the app geofences the cheapest station in the user's chosen +> radius and sends a notification when they approach it. Always-location is +> only requested when the user enables alerts. The app also provides a +> home-screen widget showing the current cheapest station nearby. + +## Release checklist (before every submission) + +1. [ ] Relay/govuk data source live on a public HTTPS endpoint +2. [ ] Always-permission flow tied to explicit user opt-in +3. [ ] Bump `CFBundleShortVersionString` / `CFBundleVersion` +4. [ ] Sample-data fallback labelled or removed in the release build +5. [ ] OGL attribution present in-app +6. [ ] `swift test` green (FuelBoardTests, 28 tests) +7. [ ] Security audit pass (see SECURITY.md recurring checks) +8. [ ] Icon + screenshots for all required sizes in App Store Connect diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..69c5dbe --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,78 @@ +# FuelBoard Backlog + +FuelBoard — England-wide petrol-station price widget + app. +Relay mirrors the UK gov data (hourly CSV); app fetches the full-UK dump +(~8,038 stations, all prices) once per 12 h and filters/sorts on-device. + +Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea. +Status: TODO / IN PROGRESS / DONE / BLOCKED. + +--- + +## P0 — Next build + +- [ ] **Switch dataset to govuk API data** (BLOCKED on credentials) + Register at developer.fuel-finder.service.gov.uk (GOV.UK One Login / OAuth 2.0). + Relay already supports an OAuth provider upgrade path — `client_id`/`client_secret` + go into relay `.env`, never into the IPA. Live API replaces the hourly CSV mirror; + endpoint/schema mapping (E5/E10/DIESEL → FuelType) already proven in + `FuelPriceProvider.swift`. Until credentials arrive, the CSV mirror stays. + Any credentials received must be treated as secrets ([REDACTED] storage). + +## P1 — Soon + +- [ ] **Unit test target for the app** — XCTest target covering: + - `FuelStore.sanitizedStationTitle` (title-case chunks, Sainsbury's apostrophe, + acronyms BP/MFG/ASDA/MOTO, hyphen/paren chunks, mixed-case passthrough) + - decode price guard: grades outside 50–500 pence/litre dropped + (`FuelPriceProvider.allPrices` + legacy `price` fallback) + - RAG thresholds (≤1.5p green / ≤4p amber / >4p red) + - Cheapest-vs-Closest sort + delta/TOP baseline (cheapest-within-radius both modes) + - miles→km and km→miles conversions (× 1.60934 / × 0.621371) +- [ ] **Backlog hygiene** — mark implemented items DONE, prune obsolete entries +- [ ] **EV charging?** — National Chargepoint Registry as a 4th "Charging" tab + (second relay endpoint, ~40k+ UK chargers, OGL, connector-type filter) vs fuel-only. + +## P2 — Later + +- [ ] **Widget configuration** — let the widget's fuel be chosen per-widget + (App Intent `FuelSelectionIntent` + `StaticConfiguration` → `AppIntentConfiguration`), + instead of inheriting the app's selected fuel. Requires iOS 17 `AppIntentConfiguration`. +- [ ] **CarPlay** — widgets render but can't launch Maps; consider a proper CarPlay + scene (CPListTemplate) for navigation deep-links. +- [ ] **Alert region tuning** — make alert trigger radius + dedup window + user-configurable (currently fixed 300 m trigger / 1 per station per hour). +- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state + (currently `refreshable` fires but no visible progress in the row list). +- [ ] **Offline first-run** — first launch without network currently shows empty + states; bundle a sample dump so the app demos offline. +- [ ] **Localization** — UK English only today; audit string catalog for l10n. +- [ ] **Dark mode polish** — verify brand logos (white-circle clips) and RAG dot + contrast under dark appearance on-device. + +## P3 — Ideas / Research + +- [ ] **Fuel-price prediction** — trend from historical dumps (relay could keep a + rolling archive) to show "price tends up/down". +- [ ] **Price alerts for a specific station** — notify when a starred station's + price drops below a user threshold. +- [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card. +- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared + app-group store; watch target would need to be added to the project). +- [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install. + +## Done (recent) + +- [x] England-wide full-UK dump, on-device filtering (relay `142061b`, app `cf9765b`) +- [x] Cache-first, 12 h gate, pull-to-refresh only +- [x] Distance filter 5/10/15 miles, local-only changes (no re-fetch) +- [x] Closest mode = whole country, distance picker disabled +- [x] Delta/TOP baseline = cheapest-within-radius in both modes +- [x] Three-layer price sanitisation (relay band 50–500p / app decode guard / title-case) +- [x] Title-case station names (chunk-aware sanitizer) +- [x] Native large-title navigation (Mail-style collapse) — `313fe84` +- [x] E10/E5 bracket labels in picker + descriptions — `05f3ef0` +- [x] Gitea remotes created + pushed (`apt/fuelboard`, `apt/fuelboard-relay`) +- [x] Brand logos: proper 2x/3x assets, 75% smaller (315 KB → 79 KB) +- [x] Security audit (see SECURITY.md) +- [x] App Store approval gotcha review (see APPSTORE.md) diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index c3ac083..2ece08b 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -288,6 +288,7 @@ BB000000000000000000001A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -309,6 +310,7 @@ BB000000000000000000001B /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..9fb5541 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Contents.json b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FuelBoard/Assets.xcassets/brand_applegreen.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_applegreen.imageset/Contents.json index 52bba66..44595a3 100644 --- a/FuelBoard/Assets.xcassets/brand_applegreen.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_applegreen.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_applegreen.png", + "filename": "brand_applegreen_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_applegreen_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen.png b/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen.png deleted file mode 100644 index 054c5cf..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen_2x.png b/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen_2x.png new file mode 100644 index 0000000..458f034 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen_3x.png b/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen_3x.png new file mode 100644 index 0000000..8371ae5 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_applegreen.imageset/brand_applegreen_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_asda.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_asda.imageset/Contents.json index f30d8b6..998d377 100644 --- a/FuelBoard/Assets.xcassets/brand_asda.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_asda.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_asda.png", + "filename": "brand_asda_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_asda_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda.png b/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda.png deleted file mode 100644 index 6034a39..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda_2x.png b/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda_2x.png new file mode 100644 index 0000000..29ae40a Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda_3x.png b/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda_3x.png new file mode 100644 index 0000000..4e882c5 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_asda.imageset/brand_asda_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_bp.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_bp.imageset/Contents.json index 3ba877a..9c1b52d 100644 --- a/FuelBoard/Assets.xcassets/brand_bp.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_bp.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_bp.png", + "filename": "brand_bp_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_bp_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp.png b/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp.png deleted file mode 100644 index 51b1031..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp_2x.png b/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp_2x.png new file mode 100644 index 0000000..7878312 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp_3x.png b/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp_3x.png new file mode 100644 index 0000000..bdfb194 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_bp.imageset/brand_bp_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_esso.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_esso.imageset/Contents.json index 9724c04..f154ea1 100644 --- a/FuelBoard/Assets.xcassets/brand_esso.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_esso.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_esso.png", + "filename": "brand_esso_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_esso_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso.png b/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso.png deleted file mode 100644 index d3a60e7..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso_2x.png b/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso_2x.png new file mode 100644 index 0000000..7c86ed2 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso_3x.png b/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso_3x.png new file mode 100644 index 0000000..79aca03 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_esso.imageset/brand_esso_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_gulf.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_gulf.imageset/Contents.json index 35b5bb5..1df5d8b 100644 --- a/FuelBoard/Assets.xcassets/brand_gulf.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_gulf.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_gulf.png", + "filename": "brand_gulf_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_gulf_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf.png b/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf.png deleted file mode 100644 index f35002a..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf_2x.png b/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf_2x.png new file mode 100644 index 0000000..5f59636 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf_3x.png b/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf_3x.png new file mode 100644 index 0000000..9ddbd96 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_gulf.imageset/brand_gulf_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_jet.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_jet.imageset/Contents.json index 9bb218b..683c58b 100644 --- a/FuelBoard/Assets.xcassets/brand_jet.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_jet.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_jet.png", + "filename": "brand_jet_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_jet_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet.png b/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet.png deleted file mode 100644 index 54efcfd..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet_2x.png b/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet_2x.png new file mode 100644 index 0000000..bcc0047 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet_3x.png b/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet_3x.png new file mode 100644 index 0000000..e3d3d9b Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_jet.imageset/brand_jet_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_morrisons.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_morrisons.imageset/Contents.json index e268eac..f0f0df2 100644 --- a/FuelBoard/Assets.xcassets/brand_morrisons.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_morrisons.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_morrisons.png", + "filename": "brand_morrisons_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_morrisons_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons.png b/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons.png deleted file mode 100644 index ad56ede..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons_2x.png b/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons_2x.png new file mode 100644 index 0000000..718b75b Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons_3x.png b/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons_3x.png new file mode 100644 index 0000000..adb6344 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_morrisons.imageset/brand_morrisons_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/Contents.json index 77563a5..c27223b 100644 --- a/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_sainsburys.png", + "filename": "brand_sainsburys_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_sainsburys_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys.png b/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys.png deleted file mode 100644 index 97a3ecd..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys_2x.png b/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys_2x.png new file mode 100644 index 0000000..46da8a7 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys_3x.png b/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys_3x.png new file mode 100644 index 0000000..866d277 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_sainsburys.imageset/brand_sainsburys_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_shell.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_shell.imageset/Contents.json index 1802a66..4e4566d 100644 --- a/FuelBoard/Assets.xcassets/brand_shell.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_shell.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_shell.png", + "filename": "brand_shell_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_shell_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell.png b/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell.png deleted file mode 100644 index 3586538..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell_2x.png b/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell_2x.png new file mode 100644 index 0000000..71088f7 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell_3x.png b/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell_3x.png new file mode 100644 index 0000000..b814d41 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_shell.imageset/brand_shell_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_tesco.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_tesco.imageset/Contents.json index b91e101..4b885d1 100644 --- a/FuelBoard/Assets.xcassets/brand_tesco.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_tesco.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_tesco.png", + "filename": "brand_tesco_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_tesco_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco.png b/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco.png deleted file mode 100644 index 2a1b9e4..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco_2x.png b/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco_2x.png new file mode 100644 index 0000000..72ad9ce Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco_3x.png b/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco_3x.png new file mode 100644 index 0000000..30d71c9 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_tesco.imageset/brand_tesco_3x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_texaco.imageset/Contents.json b/FuelBoard/Assets.xcassets/brand_texaco.imageset/Contents.json index ab320f4..8bc9db3 100644 --- a/FuelBoard/Assets.xcassets/brand_texaco.imageset/Contents.json +++ b/FuelBoard/Assets.xcassets/brand_texaco.imageset/Contents.json @@ -1,9 +1,14 @@ { "images": [ { - "filename": "brand_texaco.png", + "filename": "brand_texaco_2x.png", "idiom": "universal", "scale": "2x" + }, + { + "filename": "brand_texaco_3x.png", + "idiom": "universal", + "scale": "3x" } ], "info": { diff --git a/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco.png b/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco.png deleted file mode 100644 index 1aa38e5..0000000 Binary files a/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco.png and /dev/null differ diff --git a/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco_2x.png b/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco_2x.png new file mode 100644 index 0000000..a41a288 Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco_2x.png differ diff --git a/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco_3x.png b/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco_3x.png new file mode 100644 index 0000000..624ad6e Binary files /dev/null and b/FuelBoard/Assets.xcassets/brand_texaco.imageset/brand_texaco_3x.png differ diff --git a/FuelBoardTests/Package.swift b/FuelBoardTests/Package.swift new file mode 100644 index 0000000..7df042d --- /dev/null +++ b/FuelBoardTests/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FuelBoardShared", + platforms: [.macOS(.v13)], + products: [ + .library(name: "FuelBoardShared", targets: ["FuelBoardShared"]) + ], + targets: [ + // Compiles the REAL app sources via symlinks into Shared/ — tests + // exercise production code, not a copy. + .target( + name: "FuelBoardShared", + path: "Sources/FuelBoardShared" + ), + .testTarget( + name: "FuelBoardSharedTests", + dependencies: ["FuelBoardShared"], + path: "Tests/FuelBoardSharedTests" + ), + ] +) diff --git a/FuelBoardTests/Sources/FuelBoardShared/FuelPriceProvider.swift b/FuelBoardTests/Sources/FuelBoardShared/FuelPriceProvider.swift new file mode 120000 index 0000000..67cc574 --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/FuelPriceProvider.swift @@ -0,0 +1 @@ +../../../Shared/FuelPriceProvider.swift \ No newline at end of file diff --git a/FuelBoardTests/Sources/FuelBoardShared/FuelStore.swift b/FuelBoardTests/Sources/FuelBoardShared/FuelStore.swift new file mode 120000 index 0000000..8e7e4fd --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/FuelStore.swift @@ -0,0 +1 @@ +../../../Shared/FuelStore.swift \ No newline at end of file diff --git a/FuelBoardTests/Sources/FuelBoardShared/WidgetLocationFetcher.swift b/FuelBoardTests/Sources/FuelBoardShared/WidgetLocationFetcher.swift new file mode 120000 index 0000000..df8e9ff --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/WidgetLocationFetcher.swift @@ -0,0 +1 @@ +../../../Shared/WidgetLocationFetcher.swift \ No newline at end of file diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift new file mode 100644 index 0000000..015443c --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -0,0 +1,221 @@ +import XCTest +@testable import FuelBoardShared + +// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`. +// Covers the pure logic that drives the app + widget: title sanitizer, price +// guard, RAG, sorting baselines, distance, brand normalization, fuel labels. + +final class TitleSanitizerTests: XCTestCase { + func testAllCapsToTitleCase() { + XCTAssertEqual("SHELL SALTERHEBBLE".sanitizedStationTitle, "Shell Salterhebble") + XCTAssertEqual("TESCO EXPRESS YORK".sanitizedStationTitle, "Tesco Express York") + } + + func testSainsburysApostrophe() { + XCTAssertEqual("SAINSBURYS HALIFAX".sanitizedStationTitle, "Sainsbury's Halifax") + XCTAssertEqual("SAINSBURYS".sanitizedStationTitle, "Sainsbury's") + } + + func testAcronymsStayUppercase() { + XCTAssertEqual("BP HESSLE".sanitizedStationTitle, "BP Hessle") + XCTAssertEqual("MFG BRIGHOUSE".sanitizedStationTitle, "MFG Brighouse") + XCTAssertEqual("ASDA WAKEFIELD".sanitizedStationTitle, "ASDA Wakefield") + XCTAssertEqual("MOTO WETHERBY".sanitizedStationTitle, "MOTO Wetherby") + } + + func testConnectorsLowercase() { + XCTAssertEqual("GULF OIL OF MANCHESTER".sanitizedStationTitle, "Gulf Oil of Manchester") + } + + func testLtd() { + XCTAssertEqual("SMITHS GARAGES LTD".sanitizedStationTitle, "Smiths Garages Ltd") + } + + func testHyphenParenChunks() { + XCTAssertEqual("NEWCASTLE-UNDER-LYME SERVICES".sanitizedStationTitle, "Newcastle-Under-Lyme Services") + XCTAssertEqual("SPAR (MEADOWHALL)".sanitizedStationTitle, "SPAR (Meadowhall)") + } + + func testMixedCasePassthrough() { + XCTAssertEqual("Shell Salterhebble".sanitizedStationTitle, "Shell Salterhebble") + XCTAssertEqual("Esso M62 EASTBOUND".sanitizedStationTitle, "Esso M62 Eastbound") + } + + func testNoLettersPassthrough() { + XCTAssertEqual("M62 J25".sanitizedStationTitle, "M62 J25") + } + + func testAmpersandChunkReset() { + XCTAssertEqual("SHELL & BP SERVICES".sanitizedStationTitle, "Shell & BP Services") + } +} + +final class PriceGuardTests: XCTestCase { + func testInBandPricesKept() throws { + let json = """ + {"stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9,"E5":144.9,"DIESEL":142.9}}]} + """ + let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8)) + XCTAssertEqual(stations.count, 1) + XCTAssertEqual(stations[0].prices[.e10], 137.9) + XCTAssertEqual(stations[0].prices[.e5], 144.9) + XCTAssertEqual(stations[0].prices[.diesel], 142.9) + } + + func testOutOfBandPricesDropped() throws { + let json = """ + {"stations":[{"id":"s1","name":"GARBAGE","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":1.3,"E5":1589.0,"DIESEL":137.9}}]} + """ + let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8)) + XCTAssertEqual(stations.count, 1) + XCTAssertNil(stations[0].prices[.e10], "1.3p garbage must be dropped") + XCTAssertNil(stations[0].prices[.e5], "1589p garbage must be dropped") + XCTAssertEqual(stations[0].prices[.diesel], 137.9, "in-band price kept") + } + + func testBoundaryValues() throws { + let json = """ + {"stations":[{"id":"s1","name":"BOUNDARY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":50.0,"E5":500.0,"DIESEL":49.9}}]} + """ + let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8)) + XCTAssertEqual(stations[0].prices[.e10], 50.0, "lower bound inclusive") + XCTAssertEqual(stations[0].prices[.e5], 500.0, "upper bound inclusive") + XCTAssertNil(stations[0].prices[.diesel], "49.9 below band dropped") + } + + func testB7VariantsMapToDiesel() throws { + // A station sells ONE diesel grade — test each variant individually. + for (grade, price) in [("B7S", 141.9), ("B7P", 143.9), ("B10", 140.9)] { + let json = """ + {"stations":[{"id":"s1","name":"DIESEL MAP","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"\(grade)":\(price)}}]} + """ + let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8)) + XCTAssertEqual(stations[0].prices[.diesel], price, "\(grade) maps to diesel") + } + } + + func testLegacyPriceFallback() throws { + let json = """ + {"stations":[{"id":"s1","name":"LEGACY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":136.5}]} + """ + let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8)) + XCTAssertEqual(stations[0].prices[.e10], 136.5, "legacy price maps to E10") + } + + func testLegacyGarbagePriceDropped() throws { + let json = """ + {"stations":[{"id":"s1","name":"LEGACY BAD","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":1589.0}]} + """ + let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8)) + XCTAssertTrue(stations[0].prices.isEmpty, "garbage legacy price dropped") + } + + func testRelayNameSanitizedAtDecode() throws { + let json = """ + {"stations":[{"id":"s1","name":"TESCO EXPRESS LEEDS","brand":"Tesco","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":137.9}}]} + """ + let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8)) + XCTAssertEqual(stations[0].name, "Tesco Express Leeds") + } +} + +final class RAGTests: XCTestCase { + func testGreenWithinOneAndHalf() { + XCTAssertEqual(RAGRating.rating(price: 138.0, cheapest: 137.0), .green) + XCTAssertEqual(RAGRating.rating(price: 138.5, cheapest: 137.0), .green, "exactly 1.5p is green") + } + + func testAmberWithinFour() { + XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber) + XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber) + XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber, "exactly 4p is amber") + } + + func testRedBeyondFour() { + XCTAssertEqual(RAGRating.rating(price: 141.1, cheapest: 137.0), .red) + } +} + +final class SortBaselineTests: XCTestCase { + // Baseline = cheapest within the chosen radius (both Cheapest and Closest). + // TOP badge goes to the cheapest station in the pool. + func testCheapestFirstSorting() { + let a = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [.e10: 140.0], priceUpdated: nil) + let b = FuelStation(id: "b", name: "B", brand: "X", address: "", postcode: "", lat: 53.71, lng: -1.81, prices: [.e10: 137.0], priceUpdated: nil) + let c = FuelStation(id: "c", name: "C", brand: "X", address: "", postcode: "", lat: 53.72, lng: -1.82, prices: [.e10: 139.0], priceUpdated: nil) + let sorted = [a, b, c].sorted { $0.prices[.e10]! < $1.prices[.e10]! } + XCTAssertEqual(sorted.map(\.id), ["b", "c", "a"]) + } +} + +final class DistanceTests: XCTestCase { + func testZeroDistance() { + let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [:], priceUpdated: nil) + XCTAssertEqual(s.distanceKM(to: 53.7, lng2: -1.8), 0, accuracy: 0.001) + } + + func testKnownDistance() { + // London (51.5074, -0.1278) → Manchester (53.4808, -2.2426) ≈ 262 km + let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 51.5074, lng: -0.1278, prices: [:], priceUpdated: nil) + XCTAssertEqual(s.distanceKM(to: 53.4808, lng2: -2.2426), 262, accuracy: 5) + } + + func testMilesConversion() { + // 5 miles ≈ 8.05 km (app uses × 1.60934) + XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001) + // 262 km ≈ 162.8 miles (display uses × 0.621371) + XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1) + } +} + +final class BrandTests: XCTestCase { + private func station(brand: String) -> FuelStation { + FuelStation(id: UUID().uuidString, name: "N", brand: brand, address: "", postcode: "", lat: 0, lng: 0, prices: [:], priceUpdated: nil) + } + + func testKnownBrands() { + XCTAssertEqual(station(brand: "SHELL LEEDS ROAD").brandImageName, "brand_shell") + XCTAssertEqual(station(brand: "SAINSBURYS").brandImageName, "brand_sainsburys") + XCTAssertEqual(station(brand: "MORRISONS").brandImageName, "brand_morrisons") + XCTAssertEqual(station(brand: "TESCO EXPRESS").brandImageName, "brand_tesco") + XCTAssertEqual(station(brand: "BP").brandImageName, "brand_bp") + XCTAssertEqual(station(brand: "ESSO").brandImageName, "brand_esso") + XCTAssertEqual(station(brand: "ASDA").brandImageName, "brand_asda") + XCTAssertEqual(station(brand: "GULF").brandImageName, "brand_gulf") + XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet") + XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco") + XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen") + } + + func testUnknownBrand() { + XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName) + XCTAssertNil(station(brand: "VALERO").brandImageName) + XCTAssertNil(station(brand: "").brandImageName) + } +} + +final class FuelTypeLabelTests: XCTestCase { + func testDisplayNames() { + XCTAssertEqual(FuelType.e10.displayName, "Unleaded (E10)") + XCTAssertEqual(FuelType.e5.displayName, "Premium (E5)") + XCTAssertEqual(FuelType.diesel.displayName, "Diesel") + } +} + +final class FavouriteRefreshTests: XCTestCase { + func testRefreshedFavouritesApplyFreshPrices() { + let fav = FuelStation(id: "s1", name: "OLD NAME", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil) + let fresh = FuelStation(id: "s1", name: "Fresh Station", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 132.9], priceUpdated: nil) + let updated = FuelStore.refreshedFavourites([fav], from: [fresh]) + XCTAssertEqual(updated.count, 1) + XCTAssertEqual(updated[0].name, "Fresh Station") + XCTAssertEqual(updated[0].prices[.e10], 132.9) + } + + func testRefreshedFavouritesKeepUnmatchedSnapshot() { + let fav = FuelStation(id: "s1", name: "Cached", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil) + let updated = FuelStore.refreshedFavourites([fav], from: []) + XCTAssertEqual(updated[0].name, "Cached", "unmatched favourite keeps its snapshot") + XCTAssertEqual(updated[0].prices[.e10], 140.0) + } +} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a8b445d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,94 @@ +# FuelBoard Security Audit + +Audit date: 2026-08-11 · Scope: app, widget, relay, build pipeline, git history. + +## Summary + +No critical issues. No secrets in source, git history, or binaries. One +architecture note (relay IP baked into the app) is scheduled for removal by +the govuk-API switch (BACKLOG P0). Findings below are ordered by severity. + +## Findings + +### 1. LAN IP baked into app binary — MEDIUM (planned fix) + +- `Shared/FuelPriceProvider.swift:52` — `baseURL = URL(string: "http://192.168.1.131:8788")!` +- Confirmed present in the built app binary (`strings`). +- **Why it works**: ATS exempts raw numeric IP addresses (documented Apple + behaviour), so no `NSAllowsArbitraryLoads` exception exists in the plist — + the plist is clean. +- **Risk**: the relay is a private LAN host; the app is unusable off the + user's network, and the URL is effectively a hardcoded endpoint. +- **Fix**: the govuk API switch (BACKLOG P0) replaces this with the HTTPS + gov.uk endpoint via the relay. For any future public build, the app should + talk to an HTTPS domain, never a raw IP. + +### 2. No secrets in source / git / binary — PASS + +- App + widget sources: no API keys, tokens, or passwords (only keychain + API usage in `FuelStore.swift` and OAuth *documentation* comments in + `FuelPriceProvider.swift`). +- Relay: `CLIENT_ID`/`CLIENT_SECRET` read from environment only + (`app/main.py:31-32`); `.env` is gitignored; `__pycache__`/`*.pyc` + untracked; `.env.example` ships empty placeholders. +- Binary scan: only the relay IP, `maps.apple.com`, and `group.com.apt.fuelboard` + strings found. No credentials. + +### 3. Price data hardening — PASS (three layers) + +- Relay rejects out-of-band rows at the edge (`_fuel_price` 50–500 pence/litre). +- App drops out-of-band grades at decode (`FuelPriceProvider.decodeStations`, + previously `c1edd4d`). +- Station names title-cased at decode + on cache load (`aee890e`). +- Covered by unit tests (`FuelBoardTests` — `PriceGuardTests`). + +### 4. Keychain / app-group hygiene — PASS + +- Keychain access groups: `com.apt.fuelboard.shared` listed FIRST + (default access group), then `com.apt.fuelboard` — matches the proven + WidgetBoard pattern for free-account keychain sharing. +- Stations stored in app-group UserDefaults only (~2.9 MB exceeds keychain + limits; documented in `FuelStore.swift`). Stored data is public price data — + not sensitive. +- `kSecAttrAccessibleAfterFirstUnlock` — widget can read after reboot without + unlock. Correct for background widget refresh. + +### 5. Transport security — PASS for LAN dev, MUST CHANGE for public + +- No `NSAllowsArbitraryLoads` anywhere (confirmed by plist + pbxproj grep). +- Cleartext HTTP only to the LAN relay IP (ATS-exempt). Once the relay moves + behind an HTTPS domain (govuk switch), no ATS changes are needed. + +### 6. Privacy / permissions — review for App Store + +- `NSLocationWhenInUseUsageDescription` + `NSLocationAlwaysAndWhenInUseUsageDescription` + both present with clear copy; `UIBackgroundModes: location` justified by the + alert feature (geofenced cheapest-station notifications). +- Location is the only sensitive permission requested. No contacts, photos, + camera, mic. +- No third-party SDKs → no SDK privacy manifests required. + +## What was checked + +- [x] Source secret scan (app, widget, shared, relay, config) +- [x] Git history scan (all 43 commits, both repos) +- [x] Built binary string scan (app + widget) +- [x] Entitlements (app groups, keychain groups, ordering) +- [x] Info.plist (ATS, permissions, background modes) +- [x] Relay env handling + gitignore + pycache hygiene +- [x] Data validation layers (relay band, app decode guard, sanitizer) + +## Recurring checks + +Run before every release: + +```bash +# secrets in working tree +grep -rniE "api[_-]?key|client[_-]?secret|password|token" FuelBoard/ Shared/ FuelBoardWidgets/ --include="*.swift" + +# secrets in git history (both repos) +git grep -ilE "client_secret|api_key|password" $(git rev-list --all) | head + +# binary leak check (after build) +strings | grep -iE "192\.168|100\.120|client_secret|api_key" +``` diff --git a/Shared/FuelPriceProvider.swift b/Shared/FuelPriceProvider.swift index 320fbaf..fbd795c 100644 --- a/Shared/FuelPriceProvider.swift +++ b/Shared/FuelPriceProvider.swift @@ -22,6 +22,25 @@ enum FuelPriceProvider { /// Default: the keyless relay (full-UK Fuel Finder data). Falls back to /// the England-wide sample set when the relay is unreachable. static let active: FuelPriceProviding = RelayFuelProvider() + + /// Decodes a relay payload into stations, applying the defensive price + /// band. Internal so the unit-test target can exercise the guard. + static func decodeStations(from data: Data) throws -> [FuelStation] { + let payload = try JSONDecoder().decode(RelayResponse.self, from: data) + return payload.stations.map { relay in + FuelStation( + id: relay.id ?? relay.name ?? UUID().uuidString, + name: (relay.name ?? "Unknown").sanitizedStationTitle, + brand: relay.brand ?? "", + address: relay.address ?? "", + postcode: relay.postcode ?? "", + lat: relay.lat ?? 0, + lng: relay.lng ?? 0, + prices: relay.allPrices, + priceUpdated: nil + ) + } + } } // MARK: - Relay provider (default) @@ -54,20 +73,7 @@ struct RelayFuelProvider: FuelPriceProviding { guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw FuelProviderError.relayUnavailable } - let payload = try JSONDecoder().decode(RelayResponse.self, from: data) - return payload.stations.map { relay in - FuelStation( - id: relay.id ?? relay.name ?? UUID().uuidString, - name: (relay.name ?? "Unknown").sanitizedStationTitle, - brand: relay.brand ?? "", - address: relay.address ?? "", - postcode: relay.postcode ?? "", - lat: relay.lat ?? 0, - lng: relay.lng ?? 0, - prices: relay.allPrices, - priceUpdated: nil - ) - } + return try FuelPriceProvider.decodeStations(from: data) } }