package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "sync" "testing" "time" ) // A route as the Fly App's WaypointMissionSpec.toJson() writes it. const sampleRoute = `{"version":1,"id":"wp-m1x2y3","name":" Field ","points":[{"lat":55.1,"lon":12.2},{"lat":55.2,"lon":12.3}],"pois":[],` + `"cruiseSpeed":8.0,"headingMode":"auto","finishAction":"goHome","pathMode":"straight","exitOnSignalLost":true,` + `"gotoFirst":"safely","repeatTimes":1,"updatedAt":"2026-10-20T08:30:00.123Z"}` func TestParseRouteSpec(t *testing.T) { s, err := parseRouteSpec([]byte(sampleRoute)) if err != nil { t.Fatalf("parse: %v", err) } if s.id != "wp-m1x2y3" || s.name != "Field" || s.pointCount != 2 { t.Fatalf("got %+v", s) } want := time.Date(2026, 10, 20, 8, 30, 0, 123e6, time.UTC) if !s.updatedAt.Equal(want) { t.Fatalf("updatedAt = %v, want %v", s.updatedAt, want) } bad := map[string]string{ "not json": `{`, "no version": `{"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`, "version 2": `{"version":2,"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`, "no id": `{"version":1,"updatedAt":"2026-10-20T08:30:00Z"}`, "filter quote": `{"version":1,"id":"a\" || 1=1","updatedAt":"2026-10-20T08:30:00Z"}`, "no updatedAt": `{"version":1,"id":"wp-1"}`, "bad updatedAt": `{"version":1,"id":"wp-1","updatedAt":"yesterday"}`, } for name, body := range bad { if _, err := parseRouteSpec([]byte(body)); err == nil { t.Errorf("%s: accepted", name) } } } func TestRouteIsNewer(t *testing.T) { at := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC) if !routeIsNewer("2026-10-20T08:30:00.000001Z", at) { t.Error("a later stored stamp must win") } if routeIsNewer("2026-10-20T08:30:00.000000Z", at) { t.Error("an equal stamp lets the write through (idempotent retry)") } if routeIsNewer("2026-10-20T08:29:59Z", at) { t.Error("an older stored stamp loses") } if routeIsNewer("", at) { t.Error("a missing stored stamp never blocks") } } func TestRouteTimeLayoutSortsAsText(t *testing.T) { a := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC).Format(routeTimeLayout) b := time.Date(2026, 10, 20, 8, 30, 0, 500e6, time.UTC).Format(routeTimeLayout) if !(a < b) { t.Fatalf("%q should sort before %q", a, b) } if _, ok := parseRouteTime(a); !ok { t.Fatalf("stored stamp %q does not parse back", a) } } func TestRouteScope(t *testing.T) { user := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"} admin := &callerIdentity{ID: "a1", Role: roleAdmin, OrgID: "o1"} orgless := &callerIdentity{ID: "a2", Role: roleAdmin} super := &callerIdentity{ID: "s1", Role: roleSuperadmin} cases := []struct { who *callerIdentity mine bool want string }{ {user, false, `owner = "u1"`}, {admin, false, `organization = "o1"`}, {admin, true, `owner = "a1"`}, {orgless, false, `owner = "a2"`}, {super, false, ``}, {super, true, `owner = "s1"`}, } for _, c := range cases { if got := routeScopeFilter(c.who, c.mine); got != c.want { t.Errorf("%s mine=%v: %q, want %q", c.who.ID, c.mine, got, c.want) } } pilots := routeRecord{Owner: "u1", Organization: "o1"} other := routeRecord{Owner: "u9", Organization: "o2"} if !canManageRoute(user, pilots) || canManageRoute(user, routeRecord{Owner: "u2", Organization: "o1"}) { t.Error("a user manages only their own routes") } if !canManageRoute(admin, pilots) || canManageRoute(admin, other) { t.Error("an admin manages their organization's routes only") } if !canManageRoute(super, other) { t.Error("a superadmin manages everything") } } // fakeRoutesPB is just enough PocketBase for the route handlers: superuser // auth, a route_id lookup, create and patch. type fakeRoutesPB struct { mu sync.Mutex recs map[string]map[string]any // record id → fields n int } func (f *fakeRoutesPB) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() const coll = "/api/collections/routes/records" switch { case strings.HasSuffix(r.URL.Path, "/auth-with-password"): writeJSON(w, 200, map[string]any{"token": "su"}) case r.Method == http.MethodGet && r.URL.Path == coll: items := []map[string]any{} filter := r.URL.Query().Get("filter") for _, rec := range f.recs { if strings.HasPrefix(filter, "route_id = ") && filter != `route_id = "`+rec["route_id"].(string)+`"` { continue } items = append(items, rec) } writeJSON(w, 200, map[string]any{"items": items}) case r.Method == http.MethodPost && r.URL.Path == coll: var rec map[string]any _ = json.NewDecoder(r.Body).Decode(&rec) f.n++ rec["id"] = "rec" + strconv.Itoa(f.n) f.recs[rec["id"].(string)] = rec writeJSON(w, 200, rec) case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, coll+"/"): rec, ok := f.recs[strings.TrimPrefix(r.URL.Path, coll+"/")] if !ok { writeError(w, 404, "missing") return } var patch map[string]any _ = json.NewDecoder(r.Body).Decode(&patch) for k, v := range patch { rec[k] = v } writeJSON(w, 200, rec) default: writeError(w, 404, "unexpected "+r.Method+" "+r.URL.Path) } } func TestRouteHandlersLastWriterWins(t *testing.T) { pb := &fakeRoutesPB{recs: map[string]map[string]any{}} ts := httptest.NewServer(pb) defer ts.Close() s := &Server{admin: newAdminClient(ts.URL, "svc@x", "pw")} pilot := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"} stranger := &callerIdentity{ID: "u2", Role: roleUser, OrgID: "o2"} call := func(who *callerIdentity, h http.HandlerFunc, method, target, body string) (int, map[string]any) { req := httptest.NewRequest(method, target, strings.NewReader(body)) req.SetPathValue("id", strings.Split(strings.TrimPrefix(req.URL.Path, "/api/routes/"), "?")[0]) req = req.WithContext(context.WithValue(req.Context(), ctxCaller, who)) rec := httptest.NewRecorder() h(rec, req) var out map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &out) return rec.Code, out } withTime := func(at string) string { return strings.Replace(sampleRoute, "2026-10-20T08:30:00.123Z", at, 1) } if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-other", sampleRoute); code != 422 { t.Fatalf("id mismatch: %d", code) } code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute) if code != 201 { t.Fatalf("create: %d %v", code, out) } route := out["route"].(map[string]any) if route["id"] != "wp-m1x2y3" || route["name"] != "Field" || route["mine"] != true || route["pointCount"].(float64) != 2 { t.Fatalf("created view: %v", route) } stored := pb.recs["rec1"] if stored["owner"] != "u1" || stored["organization"] != "o1" { t.Fatalf("owner/org not stamped: %v", stored) } if code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T08:00:00Z")); code != 409 || out["route"] == nil { t.Fatalf("older write: %d %v", code, out) } if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute); code != 200 { t.Fatalf("idempotent retry: %d", code) } if code, _ := call(stranger, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-21T00:00:00Z")); code != 409 { t.Fatalf("someone else's id: %d", code) } if code, _ := call(stranger, s.handleGetRoute, "GET", "/api/routes/wp-m1x2y3", ""); code != 404 { t.Fatalf("stranger get: %d", code) } if code, _ := call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T08:00:00Z", ""); code != 409 { t.Fatalf("delete older than the edit: %d", code) } code, out = call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T09:00:00Z", "") if code != 200 || out["route"].(map[string]any)["deleted"] != true { t.Fatalf("delete: %d %v", code, out) } if stored["route_updated_at"] != "2026-10-20T09:00:00.000000Z" { t.Fatalf("tombstone stamp: %v", stored["route_updated_at"]) } // A save newer than the delete brings the route back. code, out = call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T10:00:00Z")) if code != 200 || out["route"].(map[string]any)["deleted"] != false { t.Fatalf("resurrect: %d %v", code, out) } }