diff --git a/auth.go b/auth.go index 5d8e3ab..93d4640 100644 --- a/auth.go +++ b/auth.go @@ -262,6 +262,9 @@ func (writer *recordingWriter) Write(payload []byte) (int, error) { return 0, writer.err } written, err := writer.w.Write(payload) + if err == nil && written < len(payload) { + err = io.ErrShortWrite + } if err != nil { writer.err = err } @@ -354,17 +357,21 @@ func (client *Client) getLocation(ctx context.Context) (url.Values, bool, error) if readErr != nil { return nil, false, fmt.Errorf("read token refresh response: %w", readErr) } - if response.StatusCode == http.StatusOK && string(body) == "ok" { - candidate := client.AuthData() - serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL) - candidate.Ssecurity = data.Ssecurity - if !serviceTokenReceived || !candidate.complete() { - return nil, false, fmt.Errorf("%w: %w", ErrReauthenticationRequired, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"}) - } - candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli() - client.setAuthData(candidate) - return nil, true, nil + if response.StatusCode != http.StatusOK { + return nil, false, &LoginError{Code: response.StatusCode, Message: string(body)} } + if string(body) != "ok" { + return nil, false, &LoginError{Code: -1, Message: string(body)} + } + candidate := client.AuthData() + serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL) + candidate.Ssecurity = data.Ssecurity + if !serviceTokenReceived || !candidate.complete() { + return nil, false, fmt.Errorf("%w: %w", ErrReauthenticationRequired, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"}) + } + candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli() + client.setAuthData(candidate) + return nil, true, nil } locationURL, err := url.Parse(data.Location) if err != nil { diff --git a/auth_test.go b/auth_test.go index a6c3204..3e24bec 100644 --- a/auth_test.go +++ b/auth_test.go @@ -27,6 +27,15 @@ type failThenBlockWriter struct { blockWrites chan struct{} } +type shortWriter struct { + calls atomic.Int32 +} + +func (writer *shortWriter) Write(payload []byte) (int, error) { + writer.calls.Add(1) + return len(payload) - 1, nil +} + func (writer *failThenBlockWriter) Write(payload []byte) (int, error) { call := writer.calls.Add(1) if call == writer.failOnCall { @@ -62,6 +71,24 @@ func TestRecordingWriterStopsAfterFirstError(t *testing.T) { } } +func TestRecordingWriterRecordsShortWriteAndStops(t *testing.T) { + underlying := &shortWriter{} + writer := &recordingWriter{w: underlying} + payload := []byte("payload") + + written, err := writer.Write(payload) + if written != len(payload)-1 || !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("first Write() = %d, %v, want %d, %v", written, err, len(payload)-1, io.ErrShortWrite) + } + written, err = writer.Write(payload) + if written != 0 || !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("second Write() = %d, %v, want 0, %v", written, err, io.ErrShortWrite) + } + if calls := underlying.calls.Load(); calls != 1 { + t.Fatalf("underlying Write calls = %d, want 1", calls) + } +} + func TestParseServiceResponse(t *testing.T) { var result struct { Code int `json:"code"` @@ -225,6 +252,54 @@ func TestRefreshWithoutNewTokenRequiresReauthentication(t *testing.T) { } } +func TestRefreshCallbackFailuresDoNotRequireReauthentication(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantCode int + }{ + {name: "server error", statusCode: http.StatusServiceUnavailable, body: "temporarily unavailable", wantCode: http.StatusServiceUnavailable}, + {name: "unexpected body", statusCode: http.StatusOK, body: "pending", wantCode: -1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v2/message/v2/check_new_msg": + _, _ = io.WriteString(writer, `{"code":-10030,"message":"expired"}`) + case "/serviceLogin": + _, _ = io.WriteString(writer, `&&&START&&&{"code":0,"location":"`+server.URL+`/refresh","ssecurity":"`+testSsecurity+`"}`) + case "/refresh": + writer.WriteHeader(test.statusCode) + _, _ = io.WriteString(writer, test.body) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := testClient(t, server.Client()) + client.baseURL = server.URL + client.serviceLoginURL = server.URL + "/serviceLogin" + client.availabilityValid = false + + err := client.refreshToken(context.Background()) + if errors.Is(err, ErrReauthenticationRequired) { + t.Fatalf("refreshToken() error = %v, do not want ErrReauthenticationRequired", err) + } + var loginErr *LoginError + if !errors.As(err, &loginErr) { + t.Fatalf("refreshToken() error = %v, want LoginError", err) + } + if loginErr.Code != test.wantCode || !strings.Contains(loginErr.Message, test.body) { + t.Fatalf("LoginError = %#v, want code %d containing %q", loginErr, test.wantCode, test.body) + } + }) + } +} + func TestQRLoginTimeoutDoesNotRequireReauthentication(t *testing.T) { ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) defer cancel() @@ -389,6 +464,40 @@ func TestLoginReturnsQROutputErrorBeforeLongPoll(t *testing.T) { } } +func TestLoginReturnsShortQROutputErrorBeforeLongPoll(t *testing.T) { + var longPollRequests atomic.Int32 + qrWriter := &shortWriter{} + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/serviceLogin": + _, _ = io.WriteString(writer, `&&&START&&&{"code":70016,"location":"`+server.URL+`/prepare"}`) + case "/loginUrl": + _, _ = io.WriteString(writer, `&&&START&&&{"code":0,"loginUrl":"https://qr.example/login","lp":"`+server.URL+`/lp"}`) + case "/lp": + longPollRequests.Add(1) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client, err := NewClient(t.TempDir(), WithHTTPClient(server.Client()), WithQRWriter(qrWriter)) + if err != nil { + t.Fatal(err) + } + client.serviceLoginURL = server.URL + "/serviceLogin" + client.loginURL = server.URL + "/loginUrl" + + _, err = client.Login(context.Background()) + if !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("Login() error = %v, want %v", err, io.ErrShortWrite) + } + if requests := longPollRequests.Load(); requests != 0 { + t.Fatalf("long-poll requests = %d, want 0", requests) + } +} + func TestQRLoginRejectsIncompleteCallbackWithoutSaving(t *testing.T) { var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/device.go b/device.go index 7711b03..f20abef 100644 --- a/device.go +++ b/device.go @@ -161,7 +161,7 @@ func fetchDeviceInfo(ctx context.Context, httpClient *http.Client, model string) if err != nil { return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) } - if err := validateDeviceInfo(info, model); err != nil { + if err := validateDeviceInfo(&info, model); err != nil { return DeviceInfo{}, fmt.Errorf("%w: %w", &GetDeviceInfoError{DeviceModel: model}, err) } return info, nil @@ -546,21 +546,27 @@ func decodeDeviceInfo(data []byte, model string) (DeviceInfo, error) { } info.Actions = append(info.Actions, action) } - if err := validateDeviceInfo(info, model); err != nil { + if err := validateDeviceInfo(&info, model); err != nil { return DeviceInfo{}, err } return info, nil } -func validateDeviceInfo(info DeviceInfo, model string) error { +func validateDeviceInfo(info *DeviceInfo, model string) error { if info.Model == "" || info.Model != model { return fmt.Errorf("model %q does not match requested model %q", info.Model, model) } + type propertyID struct { + siid int + piid int + } + properties := make(map[propertyID]PropertySpec, len(info.Properties)) for index, property := range info.Properties { if strings.TrimSpace(property.Name) == "" || !validPropertyType(property.Type) || (property.RW != "r" && property.RW != "w" && property.RW != "rw") || property.SIID <= 0 || property.PIID <= 0 { return fmt.Errorf("property %d is invalid", index) } + properties[propertyID{siid: property.SIID, piid: property.PIID}] = property } for index, action := range info.Actions { if strings.TrimSpace(action.Name) == "" || action.SIID <= 0 || action.AIID <= 0 { @@ -570,6 +576,11 @@ func validateDeviceInfo(info DeviceInfo, model string) error { if !validPropertyType(input.Type) || input.SIID <= 0 || input.PIID <= 0 || input.SIID != action.SIID { return fmt.Errorf("action %d input %d is invalid", index, inputIndex) } + property, exists := properties[propertyID{siid: input.SIID, piid: input.PIID}] + if !exists { + return fmt.Errorf("action %d input %d does not reference a property", index, inputIndex) + } + info.Actions[index].Inputs[inputIndex] = property } } return nil diff --git a/device_test.go b/device_test.go index 9961233..59795a1 100644 --- a/device_test.go +++ b/device_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "sync" "testing" @@ -267,6 +268,36 @@ func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) { } } +func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) { + const cache = `{ + "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 = `{ + "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