Backlog, tests, logo compression, security audit, App Store icon
- BACKLOG.md: P0-P3 roadmap incl. govuk API switch (blocked on credentials) - APPSTORE.md: review-gate analysis (2 blockers: LAN data source, Always-location notes) - SECURITY.md: audit - no secrets, no ATS holes, one MEDIUM (relay IP in binary, planned fix) - FuelBoardTests/: SPM package, 28 tests on real Shared/ sources (sanitizer, price guard, RAG, sort, distance, brands) - Logos: proper 2x/3x pairs, palette-quantized, 75% smaller (315KB -> 79KB) - App icon: 1024px fuelpump on gradient (was missing entirely -> instant rejection) - decodeStations(from:) exposed for testability; relay fetch reuses it
@@ -3,3 +3,4 @@ build-release/
|
||||
.DS_Store
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
FuelBoardTests/.build/
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 826 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
../../../Shared/FuelPriceProvider.swift
|
||||
@@ -0,0 +1 @@
|
||||
../../../Shared/FuelStore.swift
|
||||
@@ -0,0 +1 @@
|
||||
../../../Shared/WidgetLocationFetcher.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)
|
||||
}
|
||||
}
|
||||
@@ -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 <app-binary> | grep -iE "192\.168|100\.120|client_secret|api_key"
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||