package smarthome import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" ) // statesFixture is a trimmed /api/states response from a Home Assistant with // one light, one switch, one sensor and two entities Maven must ignore. const statesFixture = `[ {"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}}, {"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}}, {"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"unit_of_measurement":"°C"}}, {"entity_id":"person.kami","state":"home","attributes":{}}, {"entity_id":"automation.wake","state":"on","attributes":[]} ]` func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) { t.Helper() srv := httptest.NewServer(h) t.Cleanup(srv.Close) c := NewClient(Config{URL: srv.URL, Token: "tok"}) return c, srv } func TestStatesFiltersAndNames(t *testing.T) { var auth string c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { auth = r.Header.Get("Authorization") if r.URL.Path != "/api/states" { t.Errorf("path = %q", r.URL.Path) } _, _ = w.Write([]byte(statesFixture)) }) got, err := c.States(context.Background()) if err != nil { t.Fatalf("States: %v", err) } if auth != "Bearer tok" { t.Errorf("Authorization = %q", auth) } // person and automation are neither controllable nor sensors. want := []string{"light.living_room", "sensor.bedroom_temp", "switch.kettle"} if len(got) != len(want) { t.Fatalf("got %d entities, want %d: %+v", len(got), len(want), got) } for i, id := range want { if got[i].ID != id { t.Errorf("entity %d = %q, want %q (sorted by id)", i, got[i].ID, id) } } if got[0].Name != "Гостиная" || got[0].Domain != "light" || got[0].State != "on" { t.Errorf("light = %+v", got[0]) } if got[1].Unit != "°C" { t.Errorf("sensor unit = %q", got[1].Unit) } // An attributes value of the wrong shape must not lose the entity. if got[2].Name != "Чайник" { t.Errorf("switch name = %q", got[2].Name) } } func TestStatesRespectsConfiguredDomainsAndCap(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(statesFixture)) })) defer srv.Close() c := NewClient(Config{URL: srv.URL, Token: "t", Domains: []string{"switch"}}) got, err := c.States(context.Background()) if err != nil { t.Fatalf("States: %v", err) } if len(got) != 1 || got[0].ID != "switch.kettle" { t.Fatalf("domain filter: %+v", got) } c = NewClient(Config{URL: srv.URL, Token: "t", MaxEntities: 2}) got, err = c.States(context.Background()) if err != nil { t.Fatalf("States: %v", err) } if len(got) != 2 { t.Fatalf("cap: got %d entities, want 2", len(got)) } } func TestCallServicePostsEntityID(t *testing.T) { var path, body string c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { path = r.URL.Path b := make([]byte, 256) n, _ := r.Body.Read(b) body = string(b[:n]) _, _ = w.Write([]byte(`[{"entity_id":"light.living_room","state":"off"}]`)) }) out, err := c.CallService(context.Background(), "light.living_room", "turn_off") if err != nil { t.Fatalf("CallService: %v", err) } if out != "готово" { t.Errorf("out = %q", out) } if path != "/api/services/light/turn_off" { t.Errorf("path = %q", path) } if !strings.Contains(body, `"entity_id":"light.living_room"`) { t.Errorf("body = %q", body) } } // A service that is not in the domain's table never leaves the box. The // allowlist is the gate, and it is enforced on the way out too, so a corrupted // or hand-edited cmd column cannot reach an arbitrary Home Assistant service. func TestCallServiceRefusesUnknownServiceAndDomain(t *testing.T) { called := false c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { called = true _, _ = w.Write([]byte(`[]`)) }) for _, tc := range []struct { entity, service string want error }{ {"light.living_room", "delete_everything", ErrNotControllable}, {"sensor.bedroom_temp", "turn_on", ErrNotControllable}, {"nodot", "turn_on", ErrUnknownEntity}, } { if _, err := c.CallService(context.Background(), tc.entity, tc.service); !errors.Is(err, tc.want) { t.Errorf("CallService(%q,%q) err = %v, want %v", tc.entity, tc.service, err, tc.want) } } if called { t.Error("a refused call still reached the network") } } func TestHTTPErrorIsAnError(t *testing.T) { c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }) if _, err := c.States(context.Background()); err == nil { t.Fatal("want error on 401") } } func TestUnconfiguredClientRefuses(t *testing.T) { c := NewClient(Config{}) if _, err := c.States(context.Background()); !errors.Is(err, ErrNotConfigured) { t.Fatalf("err = %v, want ErrNotConfigured", err) } } func TestValidate(t *testing.T) { ok := Config{URL: "http://ha.lan:8123", Token: "t"} if err := Validate(ok); err != nil { t.Fatalf("Validate(ok): %v", err) } for name, c := range map[string]Config{ "no url": {Token: "t"}, "no token": {URL: "http://ha.lan:8123"}, "bad scheme": {URL: "ftp://ha.lan", Token: "t"}, "no host": {URL: "http://", Token: "t"}, "not a url": {URL: "://x", Token: "t"}, "bare string": {URL: "ha.lan:8123", Token: "t"}, } { if err := Validate(c); err == nil { t.Errorf("Validate(%s) = nil, want error", name) } } } func TestAllowlistEncoding(t *testing.T) { cmd := Cmd("light.living_room", "turn_off") id, svc, ok := ParseCmd(cmd) if !ok || id != "light.living_room" || svc != "turn_off" { t.Fatalf("ParseCmd(%v) = %q,%q,%v", cmd, id, svc, ok) } // Anything that is not exactly a three-element smarthome row stays a // process row, or the executor would swallow a real shell tool. for _, bad := range [][]string{ nil, {"smarthome"}, {"smarthome", "light.x"}, {"smarthome", "light.x", "turn_on", "extra"}, {"smarthome", "", "turn_on"}, {"smarthome", "light.x", ""}, {"systemctl", "restart", "nginx"}, } { if _, _, ok := ParseCmd(bad); ok { t.Errorf("ParseCmd(%v) = ok, want not a smarthome row", bad) } } if got := LocalName("light.living_room", "off"); got != "home_light_living_room_off" { t.Errorf("LocalName = %q", got) } if got := Scope("light"); got != "smarthome:light" { t.Errorf("Scope = %q", got) } if Services("light") == nil || Services("sensor") != nil { t.Error("Services: light must be controllable and sensor must not") } if DomainOf("light.x") != "light" || DomainOf("nodot") != "" || DomainOf(".x") != "" { t.Error("DomainOf") } } // Home Assistant answers a service call with the states it changed, and an // entity that has been removed or whose integration is offline gets 200 and an // empty array. "готово" for that is Maven asserting something false about the // physical world: he says выключи свет, she says done, the light stays on. func TestCallServiceOnAnEntityThatChangedNothing(t *testing.T) { c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`[]`)) }) if _, err := c.CallService(context.Background(), "light.living_room", "turn_off"); !errors.Is(err, ErrUnknownEntity) { t.Errorf("err = %v, want ErrUnknownEntity for a call that changed nothing", err) } // A response shape we cannot parse is not evidence of failure: HA answered // 2xx, so the call is reported as made. c2, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"result":"ok"}`)) }) if out, err := c2.CallService(context.Background(), "light.living_room", "turn_off"); err != nil || out != "готово" { t.Errorf("out,err = %q,%v", out, err) } } // The cap must not be spent on sensors. Entity ids sort by domain prefix and // binary_sensor sorts first, so a globally sorted truncation handed all forty // slots to connectivity sensors: propose found nothing controllable and // homeSummary said "всё выключено" with the lights on. func TestCapKeepsControllableEntitiesFirst(t *testing.T) { var all []Entity for i := 0; i < 40; i++ { all = append(all, Entity{ID: fmt.Sprintf("binary_sensor.b%02d", i), Domain: "binary_sensor", State: "off"}) } for i := 0; i < 30; i++ { all = append(all, Entity{ID: fmt.Sprintf("sensor.s%02d", i), Domain: "sensor", State: "1"}) } for i := 0; i < 4; i++ { all = append(all, Entity{ID: fmt.Sprintf("switch.w%d", i), Domain: "switch", State: "on"}) } for i := 0; i < 3; i++ { all = append(all, Entity{ID: fmt.Sprintf("light.l%d", i), Domain: "light", State: "on"}) } got := capEntities(all, 40) if len(got) != 40 { t.Fatalf("kept %d entities, want 40", len(got)) } kept := map[string]int{} for _, e := range got { kept[e.Domain]++ } if kept["switch"] != 4 || kept["light"] != 3 { t.Errorf("kept %d switches and %d lights, want all 4 and all 3: %v", kept["switch"], kept["light"], kept) } // The remainder still carries readable sensors, spread across both sensor // domains rather than exhausting the one that sorts first. if kept["sensor"] == 0 || kept["binary_sensor"] == 0 { t.Errorf("the read domains were starved: %v", kept) } // Deterministic across restarts: /tools has to read the same each time. for i := 1; i < len(got); i++ { if got[i-1].ID >= got[i].ID { t.Fatalf("output is not sorted by id at %d", i) } } } // A deadbolt is a different class of object from a lamp. A bare url+token block // must not auto-propose an unlock row for every door in the flat. func TestLockIsNotInTheDefaultDomains(t *testing.T) { c := NewClient(Config{URL: "http://x", Token: "t"}) if c.wanted("lock") { t.Error("lock is enumerated without being named in domains") } if !c.wanted("light") || !c.wanted("sensor") { t.Error("the ordinary default domains were lost") } named := NewClient(Config{URL: "http://x", Token: "t", Domains: []string{"lock"}}) if !named.wanted("lock") { t.Error("lock named in domains is still not enumerated") } }