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
This commit is contained in:
FuelBoard Contributor
2026-08-11 23:14:28 +01:00
parent 05f3ef0a70
commit 2ed18d81a3
57 changed files with 608 additions and 25 deletions
+1
View File
@@ -3,3 +3,4 @@ build-release/
.DS_Store .DS_Store
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
FuelBoardTests/.build/
+86
View File
@@ -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
+78
View File
@@ -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 50500 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 50500p / 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)
+2
View File
@@ -288,6 +288,7 @@
BB000000000000000000001A /* Debug */ = { BB000000000000000000001A /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -309,6 +310,7 @@
BB000000000000000000001B /* Release */ = { BB000000000000000000001B /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
Binary file not shown.

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": [ "images": [
{ {
"filename": "brand_applegreen.png", "filename": "brand_applegreen_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_applegreen_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_asda.png", "filename": "brand_asda_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_asda_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+6 -1
View File
@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_bp.png", "filename": "brand_bp_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_bp_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_esso.png", "filename": "brand_esso_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_esso_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_gulf.png", "filename": "brand_gulf_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_gulf_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+6 -1
View File
@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_jet.png", "filename": "brand_jet_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_jet_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_morrisons.png", "filename": "brand_morrisons_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_morrisons_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_sainsburys.png", "filename": "brand_sainsburys_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_sainsburys_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_shell.png", "filename": "brand_shell_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_shell_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_tesco.png", "filename": "brand_tesco_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_tesco_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,9 +1,14 @@
{ {
"images": [ "images": [
{ {
"filename": "brand_texaco.png", "filename": "brand_texaco_2x.png",
"idiom": "universal", "idiom": "universal",
"scale": "2x" "scale": "2x"
},
{
"filename": "brand_texaco_3x.png",
"idiom": "universal",
"scale": "3x"
} }
], ],
"info": { "info": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+23
View File
@@ -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)
}
}
+94
View File
@@ -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` 50500 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"
```
+20 -14
View File
@@ -22,6 +22,25 @@ enum FuelPriceProvider {
/// Default: the keyless relay (full-UK Fuel Finder data). Falls back to /// Default: the keyless relay (full-UK Fuel Finder data). Falls back to
/// the England-wide sample set when the relay is unreachable. /// the England-wide sample set when the relay is unreachable.
static let active: FuelPriceProviding = RelayFuelProvider() 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) // MARK: - Relay provider (default)
@@ -54,20 +73,7 @@ struct RelayFuelProvider: FuelPriceProviding {
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw FuelProviderError.relayUnavailable throw FuelProviderError.relayUnavailable
} }
let payload = try JSONDecoder().decode(RelayResponse.self, from: data) return try FuelPriceProvider.decodeStations(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
)
}
} }
} }