package mijia import ( "bytes" "context" "encoding/json" "errors" "fmt" "math" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "sync" "testing" "time" ) var _ = DevicePropertyResult{"x", nil, 0} type deviceTestServer struct { t *testing.T fixture []byte mu sync.Mutex specCalls int apiResults []string requests []map[string]any status int server *httptest.Server specPaths []string } func newDeviceTestServer(t *testing.T, fixture []byte, apiResults []string) *deviceTestServer { t.Helper() testServer := &deviceTestServer{t: t, fixture: fixture, apiResults: apiResults} testServer.server = httptest.NewServer(http.HandlerFunc(testServer.handle)) oldSpecURL := deviceSpecURL deviceSpecURL = testServer.server.URL + "/spec/" t.Cleanup(func() { deviceSpecURL = oldSpecURL testServer.server.Close() }) return testServer } func (testServer *deviceTestServer) handle(writer http.ResponseWriter, request *http.Request) { testServer.mu.Lock() defer testServer.mu.Unlock() if strings.HasPrefix(request.URL.Path, "/spec/") { testServer.specCalls++ testServer.specPaths = append(testServer.specPaths, request.URL.Path) if request.Method != http.MethodGet { testServer.t.Errorf("spec method = %q", request.Method) } if request.Header.Get("User-Agent") != "mijiaAPI/4.1.2" { testServer.t.Errorf("spec User-Agent = %q", request.Header.Get("User-Agent")) } status := testServer.status if status == 0 { status = http.StatusOK } writer.WriteHeader(status) _, _ = writer.Write(testServer.fixture) return } if err := request.ParseForm(); err != nil { testServer.t.Error(err) return } auth := testAuthData() decoded, err := decryptPayload(auth.Ssecurity, request.PostForm.Get("_nonce"), request.PostForm.Get("data")) if err != nil { testServer.t.Error(err) return } var payload map[string]any if err := json.Unmarshal([]byte(decoded), &payload); err != nil { testServer.t.Error(err) return } testServer.requests = append(testServer.requests, payload) if len(testServer.apiResults) == 0 { testServer.t.Errorf("unexpected API request %s", request.URL.Path) http.Error(writer, "unexpected request", http.StatusInternalServerError) return } result := testServer.apiResults[0] testServer.apiResults = testServer.apiResults[1:] _, _ = fmt.Fprintf(writer, `{"code":0,"result":%s}`, result) } func testAuthData() AuthData { return AuthData{UA: "test", DeviceID: "device", PassO: "pass", Ssecurity: testSsecurity, UserID: "user", CUserID: "cuser", ServiceToken: "token", ExpireTime: time.Now().Add(time.Hour).UnixMilli()} } func loadSpecFixture(t *testing.T) []byte { t.Helper() fixture, err := os.ReadFile("testdata/miot-spec.html") if err != nil { t.Fatal(err) } return fixture } func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) { testServer := newDeviceTestServer(t, loadSpecFixture(t), nil) httpClient := testServer.server.Client() cacheDir := t.TempDir() info, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir) if err != nil { t.Fatal(err) } if info.Name != "Test Lamp" || info.Model != "test.light.v1" || len(info.Properties) != 8 || len(info.Actions) != 2 { t.Fatalf("info = %#v", info) } if got := info.Properties[0]; got.Name != "power" || got.Description != "Power / 电源" || got.Type != "bool" || got.RW != "rw" || got.SIID != 2 || got.PIID != 1 { t.Fatalf("power = %#v", got) } if got := info.Properties[1]; got.Type != "uint" || len(got.Range) != 3 || got.Range[0].String() != "1" { t.Fatalf("brightness = %#v", got) } if got := info.Properties[2].ValueList[1]; got.Value.String() != "1" || got.Description != "On" || got.DescZhCN != "开启" { t.Fatalf("value list = %#v", got) } if info.Properties[7].Name != "outlet-power" || info.Actions[1].Name != "outlet-toggle" { t.Fatalf("duplicates = %#v / %#v", info.Properties[7], info.Actions[1]) } if got := info.Actions[0].Inputs; len(got) != 2 || got[0].PIID != 3 || got[1].PIID != 1 || got[0].Description != "Mode / 模式" || got[0].Type != "uint" || len(got[0].ValueList) != 2 { t.Fatalf("action inputs = %#v", got) } if testServer.specCalls != 1 || testServer.specPaths[0] != "/spec/test.light.v1" { t.Fatalf("spec requests = %v, want GET /spec/test.light.v1", testServer.specPaths) } cachePath := filepath.Join(cacheDir, "test.light.v1.json") stat, err := os.Stat(cachePath) if err != nil || stat.Mode().Perm() != 0o600 { t.Fatalf("cache stat = %v, %v", stat, err) } cacheData, err := os.ReadFile(cachePath) if err != nil { t.Fatal(err) } var pythonCache struct { Version int `json:"version"` Properties []struct { Method cacheMethod `json:"method"` } `json:"properties"` Actions []struct { Method cacheMethod `json:"method"` } `json:"actions"` } if err := json.Unmarshal(cacheData, &pythonCache); err != nil || pythonCache.Version != 2 || pythonCache.Properties[0].Method.SIID != 2 || pythonCache.Properties[0].Method.PIID != 1 || pythonCache.Actions[0].Method.SIID != 2 || pythonCache.Actions[0].Method.AIID != 1 { t.Fatalf("Python-compatible cache = %#v, %v", pythonCache, err) } testServer.fixture = nil cached, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir) if err != nil || cached.Name != info.Name || testServer.specCalls != 1 { t.Fatalf("cached = %#v, %v, calls=%d", cached, err, testServer.specCalls) } if len(cached.Actions[0].Inputs) != 2 || cached.Actions[0].Inputs[0].PIID != 3 || cached.Actions[0].Inputs[1].PIID != 1 { t.Fatalf("cached action inputs = %#v", cached.Actions[0].Inputs) } } func TestParseDeviceInfoRejectsUnknownActionInput(t *testing.T) { fixture := bytes.Replace(loadSpecFixture(t), []byte(`"in":[3,1]`), []byte(`"in":[99]`), 1) if _, err := parseDeviceInfoHTML(fixture); err == nil || !strings.Contains(err.Error(), "unknown property IID 99") { t.Fatalf("parse error = %v", err) } } func TestDeviceActionsDeepCopyInputs(t *testing.T) { device := Device{actions: map[string]ActionSpec{"toggle": {Inputs: []PropertySpec{{Range: []json.Number{"1", "2"}, ValueList: []ValueListItem{{Value: "1"}}}}}}} actions := device.Actions() actions["toggle"].Inputs[0].Range[0] = "changed" actions["toggle"].Inputs[0].ValueList[0].Value = "changed" got := device.actions["toggle"].Inputs[0] if got.Range[0] != "1" || got.ValueList[0].Value != "1" { t.Fatalf("internal action input mutated: %#v", got) } } func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) { const pythonCache = `{ "name": "Python Lamp", "model": "test.light.v1", "properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}], "actions": [{"name":"toggle","description":"Toggle","method":{"siid":9,"aiid":10}}] }` testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{ `{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`, `[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`, `[{"did":"a","siid":7,"piid":8,"code":0}]`, `{"did":"a","siid":9,"aiid":10,"code":0}`, }) cacheDir := t.TempDir() if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(pythonCache), 0o600); err != nil { t.Fatal(err) } client := testClient(t, testServer.server.Client()) client.baseURL = testServer.server.URL device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0), WithDeviceCacheDir(cacheDir)) if err != nil { t.Fatal(err) } if _, err := device.Get(context.Background(), "power"); err != nil { t.Fatal(err) } if err := device.Set(context.Background(), "power", true); err != nil { t.Fatal(err) } if _, err := device.RunAction(context.Background(), "toggle", nil); err != nil { t.Fatal(err) } for index, want := range []map[string]any{ {"siid": float64(2), "piid": float64(1)}, {"siid": float64(2), "piid": float64(1)}, {"siid": float64(2), "aiid": float64(1)}, } { params := testServer.requests[index+2]["params"] var request map[string]any if list, ok := params.([]any); ok { request = list[0].(map[string]any) } else { request = params.(map[string]any) } for key, value := range want { if request[key] != value { t.Fatalf("request %d %s = %#v, want %#v", index, key, request[key], value) } } } if testServer.specCalls != 1 { t.Fatalf("spec calls = %d, want 1", testServer.specCalls) } } func TestOldDeviceInfoCacheRefreshesActionInputs(t *testing.T) { for _, version := range []int{0, 1} { t.Run(fmt.Sprintf("version %d", version), func(t *testing.T) { testServer := newDeviceTestServer(t, loadSpecFixture(t), nil) cacheDir := t.TempDir() cache := fmt.Sprintf(`{ "version":%d, "name":"Old Lamp", "model":"test.light.v1", "properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}], "actions":[{"name":"toggle","siid":2,"aiid":1}] }`, version) if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil { t.Fatal(err) } info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) if err != nil || testServer.specCalls != 1 || len(info.Actions) == 0 || len(info.Actions[0].Inputs) != 2 { t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls) } }) } } func TestVersion2DeviceInfoCacheTrustsActionWithoutInputs(t *testing.T) { testServer := newDeviceTestServer(t, nil, nil) cacheDir := t.TempDir() const cache = `{ "version":2, "name":"Cached Lamp", "model":"test.light.v1", "properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}], "actions":[{"name":"toggle","siid":2,"aiid":1}] }` if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil { t.Fatal(err) } info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) if err != nil || testServer.specCalls != 0 || len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 0 { t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls) } } func TestStaleDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) { testServer := newDeviceTestServer(t, []byte("unavailable"), nil) testServer.status = http.StatusServiceUnavailable cacheDir := t.TempDir() cachePath := filepath.Join(cacheDir, "test.light.v1.json") const cache = `{"version":1,"model":"test.light.v1","properties":[],"actions":[]}` if err := os.WriteFile(cachePath, []byte(cache), 0o600); err != nil { t.Fatal(err) } _, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) if err == nil || !strings.Contains(err.Error(), "cache stale") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") { t.Fatalf("error = %v", err) } } func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) { tests := []struct { name string cache string }{ {name: "empty object", cache: `{}`}, {name: "wrong model", cache: `{"model":"other.model","properties":[],"actions":[]}`}, {name: "zero ID", cache: `{"model":"test.light.v1","properties":[{"name":"power","type":"bool","rw":"rw","siid":0,"piid":1}],"actions":[]}`}, {name: "trailing JSON", cache: `{"model":"test.light.v1","properties":[],"actions":[]} {}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { testServer := newDeviceTestServer(t, loadSpecFixture(t), nil) cacheDir := t.TempDir() cachePath := filepath.Join(cacheDir, "test.light.v1.json") if err := os.WriteFile(cachePath, []byte(test.cache), 0o600); err != nil { t.Fatal(err) } info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) if err != nil || info.Model != "test.light.v1" || testServer.specCalls != 1 { t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls) } data, err := os.ReadFile(cachePath) if err != nil { t.Fatal(err) } if _, err := decodeDeviceInfo(data, "test.light.v1"); err != nil { t.Fatalf("refreshed cache is invalid: %v", err) } }) } } func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) { const cache = `{ "version":2, "model":"test.light.v1", "properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","siid":2,"piid":1}], "actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"missing","type":"bool","rw":"rw","siid":2,"piid":99}]}] }` _, err := decodeDeviceInfo([]byte(cache), "test.light.v1") if err == nil || !strings.Contains(err.Error(), "does not reference a property") { t.Fatalf("decodeDeviceInfo() error = %v, want missing property error", err) } } func TestDecodeDeviceInfoCanonicalizesActionInputMetadata(t *testing.T) { const cache = `{ "version":2, "model":"test.light.v1", "properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","value-list":[{"value":0,"description":"Off"},{"value":1,"description":"On"}],"siid":2,"piid":1}], "actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"forged","description":"Forged","type":"string","rw":"w","range":[1,9,1],"siid":2,"piid":1}]}] }` info, err := decodeDeviceInfo([]byte(cache), "test.light.v1") if err != nil { t.Fatal(err) } if len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 1 { t.Fatalf("actions = %#v", info.Actions) } if got, want := info.Actions[0].Inputs[0], info.Properties[0]; !reflect.DeepEqual(got, want) { t.Fatalf("canonical input = %#v, want %#v", got, want) } } func TestInvalidDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) { testServer := newDeviceTestServer(t, []byte("unavailable"), nil) testServer.status = http.StatusServiceUnavailable cacheDir := t.TempDir() cachePath := filepath.Join(cacheDir, "test.light.v1.json") if err := os.WriteFile(cachePath, []byte(`{}`), 0o600); err != nil { t.Fatal(err) } _, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir) if err == nil || !strings.Contains(err.Error(), "invalid device info cache") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") { t.Fatalf("error = %v", err) } data, readErr := os.ReadFile(cachePath) if readErr != nil || string(data) != `{}` { t.Fatalf("cache changed after failed refresh: %q, %v", data, readErr) } } func TestGetDeviceInfoErrors(t *testing.T) { tests := []struct { name string status int fixture string }{ {name: "http", status: http.StatusNotFound, fixture: "not found"}, {name: "missing script", fixture: ""}, {name: "invalid json", fixture: ``}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { testServer := newDeviceTestServer(t, []byte(test.fixture), nil) testServer.status = test.status _, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "bad.model", "") var infoErr *GetDeviceInfoError if !errors.As(err, &infoErr) { t.Fatalf("error = %v", err) } if test.status != 0 && !strings.Contains(err.Error(), fmt.Sprint(test.status)) { t.Fatalf("error = %v, want status %d", err, test.status) } }) } } func TestGetDeviceInfoRejectsUnsafeModelsBeforeFileAccess(t *testing.T) { cacheDir := t.TempDir() parent := filepath.Dir(cacheDir) for _, model := range []string{"", "../escaped", "/tmp/absolute", `vendor\\model`, "vendor/model"} { t.Run(model, func(t *testing.T) { _, err := GetDeviceInfo(context.Background(), http.DefaultClient, model, cacheDir) if err == nil { t.Fatal("GetDeviceInfo() error = nil") } }) } entries, err := os.ReadDir(cacheDir) if err != nil || len(entries) != 0 { t.Fatalf("cache entries = %v, %v", entries, err) } if _, err := os.Stat(filepath.Join(parent, "escaped.json")); !os.IsNotExist(err) { t.Fatalf("escaped cache file exists: %v", err) } } func TestGetDeviceInfoRejectsOversizedContent(t *testing.T) { t.Run("cache", func(t *testing.T) { testServer := newDeviceTestServer(t, loadSpecFixture(t), nil) cacheDir := t.TempDir() cachePath := filepath.Join(cacheDir, "test.model.json") if err := os.WriteFile(cachePath, make([]byte, deviceSpecMaxSize+1), 0o600); err != nil { t.Fatal(err) } _, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", cacheDir) if err == nil || !strings.Contains(err.Error(), "model") || testServer.specCalls != 1 { t.Fatalf("error = %v, calls=%d", err, testServer.specCalls) } }) t.Run("HTML", func(t *testing.T) { testServer := newDeviceTestServer(t, make([]byte, deviceSpecMaxSize+1), nil) _, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", "") if err == nil || !strings.Contains(err.Error(), "exceeds") { t.Fatalf("error = %v, want HTML size error", err) } }) } func TestParseDeviceInfoHTMLScriptTagVariants(t *testing.T) { fixture := string(loadSpecFixture(t)) const opening = `` variants := []struct { name string opening string closing string }{ {name: "reordered", opening: ``}, {name: "unquoted attributes", opening: `