package mijia
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
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 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 {
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.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)
}
}
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, nil, []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(7), "piid": float64(8)},
{"siid": float64(7), "piid": float64(8)},
{"siid": float64(9), "aiid": float64(10)},
} {
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 != 0 {
t.Fatalf("spec calls = %d, want 0", testServer.specCalls)
}
}
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 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: `