package api import ( "encoding/json" "net/http" "strings" "testing" ) const siteA = "aaaaaaaa-1111-2222-3333-444444444444" // camPath addresses the camera the fake store creates for a given name. func camPath(cameraID string) string { return "/api/cameras/" + fakeCameraUUID(cameraID) } // ---------------------------------------------------------------- the boundary // The single most important assertion in this file. An RTSP credential is a // live path into the camera itself, and the only consumer that legitimately // needs the plaintext is the agent for its own site. func TestACameraPasswordIsNeverReturnedToAPerson(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{ "camera_id": "entrance", "label": "Entrance", "host": "192.168.0.138", "username": "admin", "password": "hunter2", }) if rec.Code != http.StatusCreated { t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), "hunter2") { t.Fatalf("the camera password came back:\n%s", rec.Body.String()) } list := do(t, s, "GET", "/api/cameras", sess.Token, nil) if strings.Contains(list.Body.String(), "hunter2") { t.Fatalf("the camera password is in the list:\n%s", list.Body.String()) } // The operator still has to be able to tell "no password set" from "a // password is set and I am simply not being shown it". if !strings.Contains(list.Body.String(), `"has_password":true`) { t.Errorf("no indication a password is stored:\n%s", list.Body.String()) } } // The agent is the one caller that gets it, and only for its own site. func TestTheAgentReceivesThePasswordItNeedsToConnect(t *testing.T) { s, fs := newServer(t) fs.addAgent("agent-token", AgentPrincipal{ AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA}) fs.agentCameras = []AgentCamera{{ CameraID: "entrance", Host: "192.168.0.138", Port: 554, Username: "admin", Password: "hunter2", Enabled: true, Revision: 1, }} req := do(t, s, "GET", "/api/agent/cameras", "agent-token", nil) if req.Code != http.StatusOK { t.Fatalf("got %d: %s", req.Code, req.Body.String()) } if !strings.Contains(req.Body.String(), "hunter2") { t.Fatal("the agent did not get the password, so it cannot connect") } } // An agent has no user, no role and no session. A person's token must not open // the agent routes, and vice versa. func TestAgentRoutesRefuseAUserSession(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") for _, call := range [][2]string{ {"GET", "/api/agent/cameras"}, {"POST", "/api/agent/cameras"}, } { rec := do(t, s, call[0], call[1], sess.Token, AgentCameraReport{}) if rec.Code != http.StatusUnauthorized { t.Errorf("%s %s: got %d, want 401", call[0], call[1], rec.Code) } } } func TestCameraRoutesNeedASession(t *testing.T) { s, fs := newServer(t) seedUser(fs) if rec := do(t, s, "GET", "/api/cameras", "", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("got %d, want 401", rec.Code) } } // ---------------------------------------------------------------- editing // The camera id is what visits are recorded against. Renaming it would orphan // every visit already attributed to the old name. func TestEditingACameraCannotRenameTheIdVisitsAreRecordedAgainst(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{ "camera_id": "entrance", "host": "10.0.0.5"}) rec := do(t, s, "PATCH", camPath("entrance"), sess.Token, map[string]any{ "camera_id": "back-door", "label": "Back door"}) if rec.Code != http.StatusOK { t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) } var cam Camera json.Unmarshal(rec.Body.Bytes(), &cam) //nolint:errcheck if cam.CameraID != "entrance" { t.Fatalf("the camera id was renamed to %q", cam.CameraID) } if cam.Label != "Back door" { t.Errorf("the label should be editable, got %q", cam.Label) } } // A blank field means "leave alone". Sending an empty password on every edit is // how a camera loses its credential the first time somebody fixes a typo in the // label. func TestAnOmittedPasswordIsNotSentToTheStore(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{ "camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"}) do(t, s, "PATCH", camPath("entrance"), sess.Token, map[string]any{"label": "Front"}) fs.mu.Lock() defer fs.mu.Unlock() if fs.lastSaved.Password != nil { t.Fatalf("an edit that did not mention the password sent %q", *fs.lastSaved.Password) } } // Staff can fill in a customer form; changing what a camera connects to is a // different kind of act. func TestStaffCannotChangeCameras(t *testing.T) { s, fs := newServer(t) fs.addUser("staff@acme.com", "correct horse battery", UserRecord{ ID: "u2", ClientID: "client-acme", Role: "staff", Active: true}) sess := login(t, s, "staff@acme.com", "correct horse battery") for _, call := range [][2]string{ {"POST", "/api/sites/" + siteA + "/cameras"}, {"PATCH", camPath("entrance")}, {"DELETE", camPath("entrance")}, } { rec := do(t, s, call[0], call[1], sess.Token, map[string]any{"host": "10.0.0.5"}) if rec.Code != http.StatusForbidden { t.Errorf("%s %s: got %d, want 403", call[0], call[1], rec.Code) } } // Reading is fine - staff need to see whether a camera is working. if rec := do(t, s, "GET", "/api/cameras", sess.Token, nil); rec.Code != http.StatusOK { t.Errorf("staff cannot see cameras at all: %d", rec.Code) } } // ---------------------------------------------------------------- input // The id ends up in an object key, a URL path and a topic segment. func TestACameraIdCannotChangeWhatAPathOrTopicMeans(t *testing.T) { for in, want := range map[string]string{ "Front Entrance": "front-entrance", "ch0/0": "ch0-0", "a+b#c": "a-b-c", " Till 2 ": "till-2", "../../etc": "etc", "!!!": "", } { if got := cameraSlug(in); got != want { t.Errorf("cameraSlug(%q) = %q, want %q", in, got, want) } } } // The message has to say what to type, not name a field. func TestACameraWithNoAddressIsRefusedWithUsableAdvice(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{"camera_id": "entrance"}) if rec.Code != http.StatusBadRequest { t.Fatalf("got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "192.168") { t.Errorf("the message should show the shape of an address: %s", rec.Body.String()) } } func TestACameraNeedsAName(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{"host": "10.0.0.5"}) if rec.Code != http.StatusBadRequest { t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) } } // A camera saved with its password silently dropped will not connect, and the // operator could not tell that from a wrong password. func TestSavingAPasswordWithNoEncryptionKeyFailsLoudly(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.saveCameraErr = ErrNoSecrets sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{ "camera_id": "entrance", "host": "10.0.0.5", "password": "hunter2"}) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("got %d, want 503: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "encryption key") { t.Errorf("the message does not name the cause: %s", rec.Body.String()) } } // ---------------------------------------------------------------- snapshots // Most deployments store no images at all, so "no picture" is the ordinary // case and must not read as a fault. func TestNoSnapshotIsDataNotAnError(t *testing.T) { s, fs := newServer(t) seedUser(fs) fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}} sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "GET", "/api/cameras", sess.Token, nil) var cams []Camera json.Unmarshal(rec.Body.Bytes(), &cams) //nolint:errcheck if cams[0].Snapshot.Available { t.Fatal("claimed a picture with no key") } if cams[0].Snapshot.Reason == "" { t.Fatal("no reason given for the missing picture") } } // A snapshot is a frame of a shop floor: a short-lived signed link, never a // stored URL, and never the raw key. func TestASnapshotIsASignedLinkAndTheKeyStaysHidden(t *testing.T) { s, fs := newServer(t) s.Blob = &fakeBlob{} seedUser(fs) fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance", Snapshot: Image{Key: "behavision/v2/acme/main/snap.jpg"}}} sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "GET", "/api/cameras", sess.Token, nil) body := rec.Body.String() if !strings.Contains(body, "X-Amz-Signature") { t.Fatalf("no signed link: %s", body) } if strings.Contains(body, `"key"`) || strings.Contains(body, `"Key"`) { t.Fatalf("the raw object key is in the response: %s", body) } } // ---------------------------------------------------------------- adoption // The agent may report its own site's state; the site comes from its // credential, never from the body. func TestAnAgentReportIsScopedByItsOwnCredential(t *testing.T) { s, fs := newServer(t) fs.addAgent("agent-token", AgentPrincipal{ AgentID: "a1", ClientID: "client-acme", Site: siteA, SiteID: siteA}) rec := do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{ State: []AgentCameraState{{CameraID: "entrance", Connected: true}}, Adopt: []AgentCamera{{CameraID: "Office Cam", Host: "192.168.0.138"}}, }) if rec.Code != http.StatusNoContent { t.Fatalf("got %d: %s", rec.Code, rec.Body.String()) } fs.mu.Lock() defer fs.mu.Unlock() if got := fs.lastCameraReport.Adopt[0].CameraID; got != "office-cam" { t.Errorf("an adopted id was not normalised: %q", got) } } // The create response must carry the same snapshot explanation the list does. // Decorating a copy and serialising the original returned an empty snapshot // object, so a freshly added camera showed no picture and no reason for it. func TestACreatedCameraExplainsItsMissingPicture(t *testing.T) { s, fs := newServer(t) seedUser(fs) sess := login(t, s, "manager@acme.com", "correct horse battery") rec := do(t, s, "POST", "/api/sites/"+siteA+"/cameras", sess.Token, map[string]any{"camera_id": "entrance", "host": "10.0.0.5"}) var cam Camera if err := json.Unmarshal(rec.Body.Bytes(), &cam); err != nil { t.Fatal(err) } if cam.Snapshot.Reason == "" { t.Fatalf("no reason for the missing picture:\n%s", rec.Body.String()) } } // AgentPrincipal carries both the tenant's uuid and its human slug, and the // slug is the one that reads correctly in a log line - which is exactly why it // gets used by mistake in a query that wants the uuid. This shipped once and // only failed against a real database. func TestAnAgentReportIsStoredAgainstTheTenantUUIDNotTheSlug(t *testing.T) { s, fs := newServer(t) fs.addAgent("agent-token", AgentPrincipal{ AgentID: "a1", ClientID: "8f1e0c2a-1111-2222-3333-444444444444", // the uuid Client: "nearle", // the slug SiteID: siteA, Site: "chennai", }) do(t, s, "POST", "/api/agent/cameras", "agent-token", AgentCameraReport{ State: []AgentCameraState{{CameraID: "entrance", Connected: true}}}) fs.mu.Lock() defer fs.mu.Unlock() if fs.lastReportClient != "8f1e0c2a-1111-2222-3333-444444444444" { t.Fatalf("stored against %q - a slug will not cast to uuid", fs.lastReportClient) } if fs.lastReportSite != siteA { t.Fatalf("site %q", fs.lastReportSite) } }