package store import ( "context" "crypto/rand" "strings" "testing" "github.com/loyaly/behavision-server/internal/api" "github.com/loyaly/behavision-server/internal/secret" ) // Live database tests for camera onboarding. // // These exist because the fake in the API package cannot catch what actually // goes wrong here: a uuid column handed a slug, an ON CONFLICT that overwrites // what it should preserve, a tombstone that a later insert quietly revives. // The first of those shipped and was caught only by running it. func sealedStore(t *testing.T) *Store { t.Helper() st := liveStore(t) var key [32]byte if _, err := rand.Read(key[:]); err != nil { t.Fatal(err) } box, err := secret.New(key[:]) if err != nil { t.Fatal(err) } st.UseSecrets(box) return st } func ptr[T any](v T) *T { return &v } func TestLiveACameraPasswordSurvivesTheRoundTripEncrypted(t *testing.T) { st := sealedStore(t) client, site := seedTenant(t, st, "cam"+stamp(), 0, false) ctx := context.Background() if _, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{ Label: ptr("Entrance"), Host: ptr("192.168.0.138"), Username: ptr("admin"), Password: ptr("office-cam-secret"), }); err != nil { t.Fatal(err) } // Nothing a person can reach carries the password. cams, err := st.Cameras(ctx, client, "") if err != nil { t.Fatal(err) } if len(cams) != 1 || !cams[0].HasPassword { t.Fatalf("camera not stored with a password: %+v", cams) } // The agent, and only the agent, gets it back. agent, err := st.AgentCameras(ctx, site) if err != nil { t.Fatal(err) } if agent[0].Password != "office-cam-secret" { t.Fatalf("password did not survive: %q", agent[0].Password) } // And it is genuinely encrypted at rest, not merely hidden by the query. var raw []byte if err := st.pool.QueryRow(ctx, `SELECT password_enc FROM site_cameras WHERE site_id = $1::uuid`, site). Scan(&raw); err != nil { t.Fatal(err) } if strings.Contains(string(raw), "office-cam-secret") { t.Fatal("the password is stored in the clear") } } // The aad is the site id, so a row copied between sites in the database does // not decrypt into a working credential. func TestLiveACameraRowCopiedToAnotherSiteDoesNotDecrypt(t *testing.T) { st := sealedStore(t) client, siteA := seedTenant(t, st, "aad"+stamp(), 0, false) ctx := context.Background() var siteB string if err := st.pool.QueryRow(ctx, ` INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, 'Other', $2) RETURNING id::text`, client, "other"+stamp()).Scan(&siteB); err != nil { t.Fatal(err) } if _, err := st.SaveCamera(ctx, client, siteA, "entrance", api.CameraInput{ Host: ptr("10.0.0.5"), Password: ptr("office-cam-secret")}); err != nil { t.Fatal(err) } // Move the row, as a database-level attacker would. if _, err := st.pool.Exec(ctx, `UPDATE site_cameras SET site_id = $1::uuid WHERE site_id = $2::uuid`, siteB, siteA); err != nil { t.Fatal(err) } got, err := st.AgentCameras(ctx, siteB) if err != nil { t.Fatal(err) } if got[0].Password != "" { t.Fatalf("a relocated row decrypted into a usable credential: %q", got[0].Password) } } // Adoption must never overwrite head office's configuration with whatever the // shop PC happens to hold - an edit made here would silently revert on the // agent's next sync. func TestLiveAdoptionNeverOverwritesHeadOffice(t *testing.T) { st := sealedStore(t) client, site := seedTenant(t, st, "adopt"+stamp(), 0, false) ctx := context.Background() if _, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{ Label: ptr("Front entrance"), Host: ptr("192.168.0.138")}); err != nil { t.Fatal(err) } // The shop PC reports an older, different configuration. if err := st.ApplyAgentReport(ctx, client, site, api.AgentCameraReport{ Adopt: []api.AgentCamera{{CameraID: "entrance", Label: "stale", Host: "10.9.9.9", Enabled: true}}, }); err != nil { t.Fatal(err) } cams, err := st.Cameras(ctx, client, "") if err != nil { t.Fatal(err) } if cams[0].Host != "192.168.0.138" || cams[0].Label != "Front entrance" { t.Fatalf("adoption clobbered head office: %+v", cams[0]) } } // A hard delete would be undone on the next sync by the very camera the // operator just removed, and they would have no idea why it kept coming back. func TestLiveADeletedCameraIsNotResurrectedByAdoption(t *testing.T) { st := sealedStore(t) client, site := seedTenant(t, st, "tomb"+stamp(), 0, false) ctx := context.Background() cam, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{Host: ptr("10.0.0.5")}) if err != nil { t.Fatal(err) } if _, err := st.DeleteCamera(ctx, client, cam.ID); err != nil { t.Fatal(err) } if err := st.ApplyAgentReport(ctx, client, site, api.AgentCameraReport{ Adopt: []api.AgentCamera{{CameraID: "entrance", Host: "10.0.0.5", Enabled: true}}, }); err != nil { t.Fatal(err) } cams, err := st.Cameras(ctx, client, "") if err != nil { t.Fatal(err) } if len(cams) != 0 { t.Fatalf("a deleted camera came back: %+v", cams) } // The agent must still be TOLD it is deleted, or it keeps running it. agent, err := st.AgentCameras(ctx, site) if err != nil { t.Fatal(err) } if len(agent) != 1 || !agent[0].Deleted { t.Fatalf("the agent was not told to stop: %+v", agent) } } // Editing one field must not blank the others - especially not the password, // which the form cannot resend because the API never returned it. func TestLiveEditingALabelKeepsTheStoredPassword(t *testing.T) { st := sealedStore(t) client, site := seedTenant(t, st, "edit"+stamp(), 0, false) ctx := context.Background() if _, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{ Label: ptr("Entrance"), Host: ptr("192.168.0.138"), Username: ptr("admin"), Password: ptr("office-cam-secret")}); err != nil { t.Fatal(err) } if _, err := st.SaveCamera(ctx, client, site, "entrance", api.CameraInput{Label: ptr("Front door")}); err != nil { t.Fatal(err) } agent, err := st.AgentCameras(ctx, site) if err != nil { t.Fatal(err) } if agent[0].Password != "office-cam-secret" { t.Fatalf("the password was lost by a label edit: %q", agent[0].Password) } if agent[0].Host != "192.168.0.138" { t.Fatalf("the address was lost: %q", agent[0].Host) } if agent[0].Label != "Front door" { t.Fatalf("the edit did not apply: %q", agent[0].Label) } // The revision has to move, or the agent will not re-apply it. if agent[0].Revision < 2 { t.Fatalf("revision %d - the shop PC would never pick this up", agent[0].Revision) } } // One tenant must not be able to write a camera into another's shop, even // naming a site id that really exists. func TestLiveACameraCannotBeWrittenIntoAnotherTenantsShop(t *testing.T) { st := sealedStore(t) mine, _ := seedTenant(t, st, "mine"+stamp(), 0, false) _, theirSite := seedTenant(t, st, "theirs"+stamp(), 0, false) if _, err := st.SaveCamera(context.Background(), mine, theirSite, "entrance", api.CameraInput{Host: ptr("10.0.0.5")}); err == nil { t.Fatal("wrote a camera into another tenant's site") } }