package api import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/cubecraft/remoterig/internal/db" "github.com/go-chi/chi/v5" ) // setupTestRouter creates a test router backed by a temp file database so // pooled connections all see the same data. func setupTestRouter(t *testing.T) (*db.DB, chi.Router) { t.Helper() database, err := db.Open(t.TempDir() + "/test.db") if err != nil { t.Fatalf("failed to open test db: %v", err) } r := chi.NewRouter() r.Get("/cameras", ListCameras(database)) r.Post("/cameras", RegisterCamera(database)) r.Get("/cameras/{id}", GetCameraDetail(database)) r.Post("/cameras/{id}/start", StartRecording(database, nil)) r.Post("/cameras/{id}/stop", StopRecording(database, nil)) r.Post("/cameras/{id}/status", PushStatus(database)) return database, r } func newReq(method, target string, body io.Reader) *http.Request { return httptest.NewRequest(method, target, body) } func assertStatus(t *testing.T, resp *http.Response, expected int) { t.Helper() if resp.StatusCode != expected { t.Errorf("expected status %d, got %d", expected, resp.StatusCode) } } func assertError(t *testing.T, resp *http.Response, expectedStatus int, want string) { t.Helper() assertStatus(t, resp, expectedStatus) body, _ := io.ReadAll(resp.Body) var e APIError if err := json.Unmarshal(body, &e); err != nil { t.Fatalf("failed to unmarshal error: %v (body: %s)", err, string(body)) } if e.Code != expectedStatus { t.Errorf("expected code %d, got %d", expectedStatus, e.Code) } if !strings.Contains(e.Error, want) { t.Errorf("expected error containing %q, got %q", want, e.Error) } } func regCamera(t *testing.T, db *db.DB) string { t.Helper() w := httptest.NewRecorder() r := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test Camera"}`)) r.Header.Set("Content-Type", "application/json") RegisterCamera(db)(w, r) return "CAM-001" } // ==================== GET /cameras ==================== func TestListCameras_Empty(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("GET", "/cameras", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusOK) var cameras []map[string]interface{} json.NewDecoder(w.Result().Body).Decode(&cameras) if cameras == nil { t.Error("expected non-nil cameras array, got nil") } } func TestListCameras_WithData(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) // Push a status sr := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{"battery_pct":85,"recording":false,"mode":"video","resolution":"4K","fps":30,"online":true}`)) sr.Header.Set("Content-Type", "application/json") sw := httptest.NewRecorder() r.ServeHTTP(sw, sr) assertStatus(t, sw.Result(), http.StatusOK) // Now list lr := newReq("GET", "/cameras", nil) lw := httptest.NewRecorder() r.ServeHTTP(lw, lr) assertStatus(t, lw.Result(), http.StatusOK) var cameras []map[string]interface{} json.NewDecoder(lw.Result().Body).Decode(&cameras) if len(cameras) != 1 { t.Errorf("expected 1 camera, got %d", len(cameras)) } } // ==================== POST /cameras (Register) ==================== func TestRegisterCamera_Success(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusCreated) } func TestRegisterCamera_WithMacAddress(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test","mac_address":"00:11:22:33:44:55"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusCreated) } func TestRegisterCamera_MissingBody(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") } func TestRegisterCamera_InvalidJSON(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras", strings.NewReader(`{not json`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") } func TestRegisterCamera_MissingRequiredFields(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras", strings.NewReader(`{"friendly_name":"Test"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required") req2 := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001"}`)) req2.Header.Set("Content-Type", "application/json") w2 := httptest.NewRecorder() r.ServeHTTP(w2, req2) assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name is required") } func TestRegisterCamera_FieldTooLong(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() longID := strings.Repeat("x", 65) req := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"`+longID+`","friendly_name":"Test"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64") longName := strings.Repeat("y", 129) req2 := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"`+longName+`"}`)) req2.Header.Set("Content-Type", "application/json") w2 := httptest.NewRecorder() r.ServeHTTP(w2, req2) assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name must be at most 128") } func TestRegisterCamera_WrongContentType(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) req.Header.Set("Content-Type", "text/plain") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusUnsupportedMediaType) } func TestRegisterCamera_NoContentType(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusCreated) } func TestRegisterCamera_BodyTooLarge(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := httptest.NewRequest("POST", "/cameras", bytes.NewReader(make([]byte, 70000))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "too large") } func TestRegisterCamera_Duplicate(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusConflict, "camera already registered") } // ==================== GET /cameras/{id} ==================== func TestGetCameraDetail_Success(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("GET", "/cameras/CAM-001", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusOK) } func TestGetCameraDetail_NotFound(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("GET", "/cameras/NONEXISTENT", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusNotFound, "camera not found") } func TestGetCameraDetail_BadID(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("GET", "/cameras/"+strings.Repeat("x", 65), nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64") } // ==================== POST /cameras/{id}/start ==================== func TestStartRecording_Success(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/start", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusOK) var resp map[string]string json.NewDecoder(w.Result().Body).Decode(&resp) if resp["status"] != "recording_started" { t.Errorf("expected recording_started, got %q", resp["status"]) } } func TestStartRecording_CameraNotFound(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras/NONEXISTENT/start", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusNotFound, "camera not found") } func TestStartRecording_MissingID(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras//start", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required") } // ==================== POST /cameras/{id}/stop ==================== func TestStopRecording_Success(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) // Start first sr := newReq("POST", "/cameras/CAM-001/start", nil) sw := httptest.NewRecorder() r.ServeHTTP(sw, sr) assertStatus(t, sw.Result(), http.StatusOK) // Now stop req := newReq("POST", "/cameras/CAM-001/stop", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusOK) var resp map[string]string json.NewDecoder(w.Result().Body).Decode(&resp) if resp["status"] != "recording_stopped" { t.Errorf("expected recording_stopped, got %q", resp["status"]) } } func TestStopRecording_CameraNotFound(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras/NONEXISTENT/stop", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusNotFound, "camera not found") } // ==================== POST /cameras/{id}/status ==================== func TestPushStatus_Success(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusOK) var resp map[string]string json.NewDecoder(w.Result().Body).Decode(&resp) if resp["status"] != "accepted" { t.Errorf("expected accepted, got %q", resp["status"]) } } func TestPushStatus_CameraNotFound(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() req := newReq("POST", "/cameras/NONEXISTENT/status", strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusNotFound, "camera not found") } func TestPushStatus_InvalidJSON(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{bad json`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") } func TestPushStatus_InvalidFPS(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":999,"online":true}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "fps must be between") } func TestPushStatus_NegativeFPS(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":-1,"online":true}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "fps must be between") } func TestPushStatus_InvalidBattery(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{"battery_pct":150,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between") } func TestPushStatus_NegativeBattery(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{"battery_pct":-5,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between") } func TestPushStatus_ModeTooLong(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"`+strings.Repeat("x", 33)+`","resolution":"1080p","fps":30,"online":true}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "mode must be at most") } func TestPushStatus_MissingBody(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) req := newReq("POST", "/cameras/CAM-001/status", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") } // ==================== Error Response Format ==================== func TestErrorResponseFormat_Consistent(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() checks := []struct { method, target, body string }{ {"GET", "/cameras/NONEXISTENT", ""}, {"POST", "/cameras", "bad json"}, {"POST", "/cameras/NONEXISTENT/start", ""}, {"POST", "/cameras/NONEXISTENT/status", "bad json"}, } for _, c := range checks { var rd io.Reader if c.body != "" { rd = strings.NewReader(c.body) } req := newReq(c.method, c.target, rd) if c.body != "" { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() r.ServeHTTP(w, req) var errResp map[string]interface{} json.NewDecoder(w.Result().Body).Decode(&errResp) if _, ok := errResp["error"]; !ok { t.Errorf("%s %s: missing 'error' key: %v", c.method, c.target, errResp) } if _, ok := errResp["code"]; !ok { t.Errorf("%s %s: missing 'code' key: %v", c.method, c.target, errResp) } } } // ==================== SQL Injection ==================== func TestSQLInjection_CameraID(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) // Chi URL params are extracted after routing, so injection attempts will // be treated as camera_ids and fail validation (too long) or return 404. // Use URL encoding for special characters to avoid httptest panics. paths := []string{ "/cameras/CAM-001%27+DROP+TABLE+cameras--", "/cameras/1+UNION+SELECT+NULL--", "/cameras/%27+OR+%27%27%3D%27", } for _, path := range paths { req := httptest.NewRequest("GET", path, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) code := w.Result().StatusCode if code != http.StatusNotFound && code != http.StatusBadRequest { t.Errorf("unexpected status %d for injection path %s", code, path) } } // Verify tables still exist req := httptest.NewRequest("GET", "/cameras", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assertStatus(t, w.Result(), http.StatusOK) } // ==================== Recording Lifecycle ==================== func TestRecordingLifecycle(t *testing.T) { db, r := setupTestRouter(t) defer db.Close() regCamera(t, db) // Start r1 := newReq("POST", "/cameras/CAM-001/start", nil) w1 := httptest.NewRecorder() r.ServeHTTP(w1, r1) assertStatus(t, w1.Result(), http.StatusOK) // Stop r2 := newReq("POST", "/cameras/CAM-001/stop", nil) w2 := httptest.NewRecorder() r.ServeHTTP(w2, r2) assertStatus(t, w2.Result(), http.StatusOK) // Start again r3 := newReq("POST", "/cameras/CAM-001/start", nil) w3 := httptest.NewRecorder() r.ServeHTTP(w3, r3) assertStatus(t, w3.Result(), http.StatusOK) } // ==================== Benchmark ==================== func BenchmarkListCameras(b *testing.B) { db2, _ := db.Open(b.TempDir() + "/bench.db") defer db2.Close() for i := 0; i < 10; i++ { id := string(rune('A'+i)) + "-CAM" h := RegisterCamera(db2) body := `{"camera_id":"` + id + `","friendly_name":"Test ` + string(rune('A'+i)) + `"}` req := newReq("POST", "/cameras", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() h(w, req) } jh := ListCameras(db2) b.ResetTimer() for i := 0; i < b.N; i++ { req := newReq("GET", "/cameras", nil) w := httptest.NewRecorder() jh(w, req) } }