package smarthome import ( "context" "errors" "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(`[]`)) }) 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") } }