GET /api/gnss/position now requires login (app.rb) -- it reveals where the receiver physically is, not something to leave open to anyone with the URL. GnssControl's sidebar icon is hidden entirely when logged out (same pattern as the tree UI's write controls) rather than shown and failing to ever get a fix. LocateControl needs no changes: it only ever reaches GNSS through GnssControl#isEnabled(), which can't turn true for a logged-out visitor if the toggle is never on the page to click. Its browser-geolocation fallback stays open to everyone -- a visitor's own device asking for their own location isn't something to gate. Extracted isLoggedIn() out of gpx.js into a shared public/js/auth.js (gnss.js needed the same check) rather than duplicating it. Verified: /api/gnss/position returns 401 logged out; the sidebar icon is actually hidden (computed display: none, not just present-but- unstyled -- checked properly this time after almost repeating the same present-vs-visible test mistake the .gpx-panel__action bug already taught).
244 lines
9.6 KiB
Ruby
244 lines
9.6 KiB
Ruby
require_relative "test_helper"
|
|
require "tempfile"
|
|
|
|
class AppTest < Minitest::Test
|
|
include AppTestHelper
|
|
|
|
def test_health
|
|
response = request.get("/api/health")
|
|
assert_equal 200, response.status
|
|
assert_equal({ "status" => "ok" }, json_body(response))
|
|
end
|
|
|
|
def test_index_renders_with_asset_versioning_and_importmap
|
|
response = request.get("/")
|
|
assert_equal 200, response.status
|
|
assert_match(/\/js\/map\.js\?v=\d+/, response.body)
|
|
assert_includes response.body, "importmap"
|
|
assert_includes response.body, "livetrack-panel"
|
|
end
|
|
|
|
def test_offline_tiles_reports_unavailable_when_no_pmtiles_present
|
|
response = request.get("/api/offline-tiles")
|
|
assert_equal({ "available" => false }, json_body(response))
|
|
end
|
|
|
|
def test_gnss_position_unavailable_with_no_fix
|
|
response = request.get("/api/gnss/position", **logged_in_env)
|
|
assert_equal({ "available" => false }, json_body(response))
|
|
end
|
|
|
|
def test_gnss_position_requires_login
|
|
assert_equal 401, request.get("/api/gnss/position").status
|
|
end
|
|
|
|
# --- auth guard ---
|
|
|
|
def test_write_routes_require_login
|
|
assert_equal 401, request.post("/api/gpx").status
|
|
assert_equal 401, request.post("/api/gpx/folder").status
|
|
assert_equal 401, request.delete("/api/gpx/file/x.gpx").status
|
|
assert_equal 401, request.delete("/api/gpx/folder/x").status
|
|
assert_equal 401, request.post("/api/gpx/move").status
|
|
end
|
|
|
|
# --- GPX/FIT upload and the tree ---
|
|
|
|
def test_gpx_upload_and_tree
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
response = post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
body = json_body(response)
|
|
assert_equal ["ride.gpx"], body["paths"]
|
|
assert_equal ["ride.gpx"], body["tree"]["children"].map { |c| c["name"] }
|
|
end
|
|
|
|
def test_gpx_file_returns_geojson_with_track_properties
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx")
|
|
assert_equal 200, response.status
|
|
track = json_body(response)["features"].find { |f| f["properties"]["kind"] == "track" }
|
|
assert_operator track["properties"]["distance_meters"], :>, 0
|
|
end
|
|
|
|
def test_geojson_upload_is_served_back_unchanged
|
|
content = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
|
|
response = post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
assert_equal ["spots.geojson"], json_body(response)["paths"]
|
|
|
|
file_response = request.get("/api/gpx/file/spots.geojson")
|
|
assert_equal 200, file_response.status
|
|
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
|
|
end
|
|
|
|
def test_download_of_a_converted_gpx_names_it_gpx_geojson
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx?download=geojson", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
|
|
end
|
|
|
|
def test_download_of_a_native_geojson_keeps_its_own_name
|
|
content = '{"type":"FeatureCollection","features":[]}'
|
|
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/spots.geojson?download=geojson", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
|
|
end
|
|
|
|
def test_download_of_the_original_file_serves_it_unconverted
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx?download=original", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx"'
|
|
assert_equal content, response.body
|
|
end
|
|
|
|
def test_download_requires_login
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=geojson").status
|
|
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=original").status
|
|
# Plain viewing (no ?download) stays open to anyone.
|
|
assert_equal 200, request.get("/api/gpx/file/ride.gpx").status
|
|
end
|
|
|
|
def test_unparseable_gpx_returns_422
|
|
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/bad.gpx")
|
|
assert_equal 422, response.status
|
|
end
|
|
|
|
def test_fit_upload_is_parsed_the_same_as_gpx
|
|
fit_bytes = build_fit_file([[100_000_000, 200_000_000], [100_000_100, 200_000_100]])
|
|
response = post_file("/api/gpx", filename: "activity.fit", content: fit_bytes, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
assert_equal ["activity.fit"], json_body(response)["paths"]
|
|
|
|
geojson = json_body(request.get("/api/gpx/file/activity.fit"))
|
|
assert_equal 2, geojson["features"][0]["geometry"]["coordinates"][0].size
|
|
end
|
|
|
|
def test_zip_upload_expands_into_its_track_entries_and_skips_the_rest
|
|
zip_bytes = build_zip(
|
|
"one.gpx" => File.read(fixture_path("sample.gpx")),
|
|
"two.fit" => build_fit_file([[100_000_000, 200_000_000]]),
|
|
"readme.txt" => "not a track file",
|
|
)
|
|
response = post_file("/api/gpx?folder=imported", filename: "export.zip", content: zip_bytes,
|
|
content_type: "application/zip", env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
paths = json_body(response)["paths"]
|
|
assert_equal ["imported/one.gpx", "imported/two.fit"].sort, paths.sort
|
|
|
|
names = json_body(request.get("/api/gpx/tree"))["children"]
|
|
.find { |c| c["name"] == "imported" }["children"].map { |c| c["name"] }
|
|
refute_includes names, "readme.txt"
|
|
end
|
|
|
|
def test_corrupt_zip_is_rejected
|
|
response = post_file("/api/gpx", filename: "bad.zip", content: "not a zip", content_type: "application/zip", env: logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_folder_create_move_and_delete
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
create = request.post("/api/gpx/folder?path=trips", **logged_in_env)
|
|
assert_equal 200, create.status
|
|
|
|
move = request.post("/api/gpx/move?from=ride.gpx&to_folder=trips", **logged_in_env)
|
|
assert_equal 200, move.status
|
|
assert_equal "trips/ride.gpx", json_body(move)["path"]
|
|
|
|
delete = request.delete("/api/gpx/file/trips/ride.gpx", **logged_in_env)
|
|
assert_equal 200, delete.status
|
|
assert_empty json_body(delete)["tree"]["children"].find { |c| c["name"] == "trips" }["children"]
|
|
end
|
|
|
|
def test_invalid_folder_path_is_rejected
|
|
response = request.post("/api/gpx/folder?path=..%2F..%2Fescape", **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
# --- LiveTrack webhook ---
|
|
|
|
def test_livetrack_webhook_requires_the_configured_secret
|
|
response = request.post("/api/livetrack/session", params: { secret: "wrong", session_id: "abc" })
|
|
assert_equal 403, response.status
|
|
end
|
|
|
|
def test_livetrack_webhook_stores_session_id_and_token
|
|
response = request.post(
|
|
"/api/livetrack/session",
|
|
params: { secret: ENV.fetch("LIVETRACK_WEBHOOK_SECRET"), session_id: "abc123", session_token: "tok" },
|
|
)
|
|
assert_equal 200, response.status
|
|
|
|
sessions = json_body(request.get("/api/livetrack/sessions"))["sessions"]
|
|
assert_equal "abc123", sessions.first["id"]
|
|
assert_equal "tok", sessions.first["token"]
|
|
end
|
|
|
|
def test_livetrack_track_unknown_session_is_404
|
|
response = request.get("/api/livetrack/sessions/does-not-exist/track")
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
# LiveTrackClient's actual page-fetching/parsing has its own tests
|
|
# (test_livetrack_client.rb, no network) and was checked against real
|
|
# Garmin sessions by hand during development — this only covers the
|
|
# route's own guard clause, not the success path, since exercising that
|
|
# here would mean a real network call in every test run.
|
|
def test_livetrack_track_session_without_token_is_404
|
|
request.post("/api/livetrack/session", params: { secret: ENV.fetch("LIVETRACK_WEBHOOK_SECRET"), session_id: "no-token" })
|
|
|
|
response = request.get("/api/livetrack/sessions/no-token/track")
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
private
|
|
|
|
def le32(n)
|
|
[n].pack("l<")
|
|
end
|
|
|
|
# Mirrors FitTest#build_fit — a minimal single-definition FIT file, just
|
|
# enough to exercise the upload -> store -> parse path end to end.
|
|
def build_fit_file(records)
|
|
definition = [0, 0].pack("CC") + [20].pack("S<") + [2].pack("C") +
|
|
[0, 4, 0x85].pack("C3") + [1, 4, 0x85].pack("C3")
|
|
definition_header = [0b01000000].pack("C")
|
|
data = records.map { |lat, lon| [0b00000000].pack("C") + le32(lat) + le32(lon) }.join
|
|
body = definition_header + definition + data
|
|
header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT"
|
|
header + body + "\x00\x00"
|
|
end
|
|
|
|
def build_zip(entries)
|
|
require "zip"
|
|
Tempfile.create(["test", ".zip"]) do |file|
|
|
file.close
|
|
Zip::File.open(file.path, create: true) do |zip|
|
|
entries.each { |name, content| zip.get_output_stream(name) { |f| f.write(content) } }
|
|
end
|
|
File.binread(file.path)
|
|
end
|
|
end
|
|
end
|