package onboarding import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "data-terminal-cloud/internal/identity" "github.com/google/uuid" ) func TestBLEDeviceErrorsDescribeActualCondition(t *testing.T) { cases := []struct { name, sql, code, message string status int }{ {"not_released", `UPDATE device SET onboarding_released_at=NULL WHERE id=$1::uuid`, "DEVICE_NOT_RELEASED", "尚未放行", 409}, {"quarantined", `UPDATE device SET status='quarantined' WHERE id=$1::uuid`, "DEVICE_QUARANTINED", "已被隔离", 403}, {"revoked", `UPDATE device SET status='revoked' WHERE id=$1::uuid`, "DEVICE_REVOKED", "已被停用", 403}, {"lifecycle_revoked", `UPDATE device SET activation_state='REVOKED' WHERE id=$1::uuid`, "DEVICE_REVOKED", "已被停用", 403}, {"lost", `UPDATE device SET activation_state='LOST' WHERE id=$1::uuid`, "DEVICE_LOST", "标记为丢失", 403}, {"tenant_inactive", `UPDATE tenant SET status='suspended' WHERE id=(SELECT tenant_id FROM device WHERE id=$1::uuid)`, "TENANT_INACTIVE", "组织已停用", 403}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newFixture(t) _, err := f.s.pool.Exec(f.ctx, tc.sql, f.d.ID) must(t, err) assertBLEAccessError(t, f, f.user, f.d.FactoryID, tc.status, tc.code, tc.message) }) } t.Run("malformed_and_unknown", func(t *testing.T) { f := newFixture(t) assertBLEAccessError(t, f, f.user, "bad-id", 400, "DEVICE_ID_INVALID", "编号格式不正确") assertBLEAccessError(t, f, f.user, "KP01-"+strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", "")[:12]), 404, "DEVICE_NOT_REGISTERED", "未找到该设备的注册记录") }) t.Run("account_reasons", func(t *testing.T) { f := newFixture(t) assertBLEAccessError(t, f, identity.Principal{Issuer: f.user.Issuer, Subject: uuid.NewString()}, f.d.FactoryID, 403, "ACCOUNT_NOT_FOUND", "没有对应的平台账号") assertBLEAccessError(t, f, f.p, f.d.FactoryID, 403, "CUSTOMER_REQUIRED", "切换到家庭账号") foreign := newFixture(t) assertBLEAccessError(t, f, foreign.user, f.d.FactoryID, 403, "DEVICE_TENANT_MISMATCH", "不属于当前账号所在组织") for _, state := range []string{"disabled", "deleted"} { _, err := f.s.pool.Exec(f.ctx, `UPDATE user_account SET status=$1 WHERE oidc_issuer=$2 AND oidc_subject=$3`, state, f.user.Issuer, f.user.Subject) must(t, err) assertBLEAccessError(t, f, f.user, f.d.FactoryID, 403, "ACCOUNT_"+strings.ToUpper(state), "当前账号已") } }) t.Run("legacy_device", func(t *testing.T) { f := newFixture(t) factoryID := "KP01-" + strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", "")[:12]) _, err := f.s.pool.Exec(f.ctx, `INSERT INTO device(tenant_id,serial_digest,model,credential_ref,factory_device_id) VALUES($1::uuid,$2,'legacy','test',$3)`, f.tenant, digest([]byte(factoryID)), factoryID) must(t, err) assertBLEAccessError(t, f, f.user, factoryID, 409, "DEVICE_ENROLLMENT_UNSUPPORTED", "尚未启用蓝牙实体确认绑定") }) t.Run("other_account_and_stale_ownership", func(t *testing.T) { f := newFixture(t) p := accountOnlyUser(t, f) token := accountToken(t, f, p) _, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) assertBLEAccessError(t, f, f.user, f.d.FactoryID, 403, "DEVICE_OWNED_BY_OTHER_ACCOUNT", "已添加到其他账号") state, err := f.s.BLEDeviceState(f.ctx, p, BLEDeviceInput{DeviceID: f.d.FactoryID}) must(t, err) if state["bound"] != true { t.Fatal(state) } _, err = f.s.CreateBLEToken(f.ctx, p, BLETokenInput{DeviceID: f.d.FactoryID}) assertBLEError(t, err, 409, "ALREADY_BOUND", "无需重复添加") _, err = f.s.pool.Exec(f.ctx, `UPDATE device SET binding_generation=binding_generation+1 WHERE id=$1::uuid`, f.d.ID) must(t, err) assertBLEAccessError(t, f, p, f.d.FactoryID, 409, "DEVICE_BINDING_STALE", "归属记录与当前设备状态不一致") }) t.Run("family_management_permission", func(t *testing.T) { f := newFixture(t) f.bound() p := accountOnlyUser(t, f) assertBLEAccessError(t, f, p, f.d.FactoryID, 403, "DEVICE_MANAGE_PERMISSION_REQUIRED", "没有该家庭的设备管理权限") _, err := f.s.pool.Exec(f.ctx, `UPDATE household_member SET role='viewer' WHERE user_id=(SELECT id FROM user_account WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3)`, f.tenant, f.user.Issuer, f.user.Subject) must(t, err) assertBLEAccessError(t, f, f.user, f.d.FactoryID, 403, "DEVICE_MANAGE_PERMISSION_REQUIRED", "没有该家庭的设备管理权限") }) } func assertBLEError(t *testing.T, err error, status int, code, message string) { t.Helper() var e *Error if !errors.As(err, &e) || e.Status != status || e.Code != code || !strings.Contains(e.Message, message) { t.Fatalf("want %d %s containing %q, got %#v", status, code, message, err) } } func assertBLEAccessError(t *testing.T, f *fixture, p identity.Principal, factoryID string, status int, code, message string) { t.Helper() _, err := f.s.BLEDeviceState(f.ctx, p, BLEDeviceInput{DeviceID: factoryID}) assertBLEError(t, err, status, code, message) _, err = f.s.CreateBLEToken(f.ctx, p, BLETokenInput{DeviceID: factoryID}) assertBLEError(t, err, status, code, message) } func TestMiniBLEDeviceErrorEnvelope(t *testing.T) { f := newFixture(t) session := randomToken(32) _, err := f.s.pool.Exec(f.ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) SELECT $1,$2,id,now()+interval '1 hour' FROM user_account WHERE tenant_id=$3::uuid AND oidc_issuer=$4 AND oidc_subject=$5`, digest([]byte(session)), digest([]byte("test-csrf")), f.tenant, f.user.Issuer, f.user.Subject) must(t, err) _, err = f.s.pool.Exec(f.ctx, `UPDATE device SET onboarding_released_at=NULL WHERE id=$1::uuid`, f.d.ID) must(t, err) mux := http.NewServeMux() f.s.RegisterRoutes(mux) for _, endpoint := range []string{"ble-device-state", "ble-bind-tokens"} { requestID := uuid.NewString() r := httptest.NewRequest("POST", "https://platform.example/api/v1/mini/"+endpoint, bytes.NewReader(requestJSON(BLEDeviceInput{DeviceID: f.d.FactoryID}))) r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Mini customer."+session) r.Header.Set("X-Request-ID", requestID) w := httptest.NewRecorder() mux.ServeHTTP(w, r) var body map[string]any must(t, json.Unmarshal(w.Body.Bytes(), &body)) if w.Code != 409 || body["code"] != "DEVICE_NOT_RELEASED" || body["message"] != "设备已登记但尚未放行,请联系管理员完成出厂放行" || body["request_id"] != requestID || body["ok"] != false { t.Fatal(w.Code, body) } if _, leaked := body["data"]; leaked { t.Fatal("error included device data", body) } } }