package onboarding import ( "context" "encoding/base64" "encoding/json" "errors" "strings" "testing" "time" "github.com/google/uuid" ) func serverFixture(t *testing.T) *fixture { f := newFixture(t) f.s.options.LegacyDeviceRegistration = false return f } func TestServerVoiceBindingAndConfig(t *testing.T) { f := serverFixture(t) code, _ := f.deviceCall("voice-config", struct{}{}, "", true) if code != 409 { t.Fatalf("unbound config: %d", code) } token := f.bleToken() rid := uuid.NewString() out, err := f.s.BindBLE(f.ctx, f.d, rid, BLEBindInput{Token: token}) must(t, err) if out["status"] != "success" || len(out) != 6 || out["device_secret"] == "" || out["RTCAppID"] != "test-app" { t.Fatal("binding success must include all five credentials directly") } f.refresh() must(t, f.s.ProcessActivation(f.ctx, f.event())) must(t, f.s.ProcessActivation(f.ctx, f.event())) if f.registrar.calls.Load() != 1 { t.Fatal("duplicate registration") } out, err = f.s.BindBLE(f.ctx, f.d, rid, BLEBindInput{Token: token}) must(t, err) if len(out) != 6 || out["RTCAppID"] != "test-app" || out["device_name"] != f.d.FactoryID || out["voice"] != nil { t.Fatal("missing binding credentials") } j := f.job() if j.Engine != nil || j.Ack != nil { t.Fatal("server registration fabricated SDK initialization") } raw, err := f.s.crypt.open(j.Config, "config:"+j.ID) must(t, err) if strings.Contains(string(raw), "product_secret") || !strings.Contains(string(raw), "device_secret") { t.Fatal("wrong platform config") } code, envelope := f.deviceCall("voice-runtime", VoiceRuntimeInput{BootID: f.d.BootID}, "", true) if code != 200 { t.Fatalf("voice runtime status %d", code) } data := envelope["data"].(map[string]any) payload := data["command"].(map[string]any)["payload"].(map[string]any) decoded, err := base64.RawURLEncoding.DecodeString(payload["config_b64"].(string)) must(t, err) if strings.Contains(string(decoded), "product_secret") { t.Fatal("product secret leaked to device") } ack := ActivationAckInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, LocalGeneration: 1} _, err = f.s.ActivationAck(f.ctx, f.d, uuid.NewString(), requestJSON(ack), ack) must(t, err) if f.job().Engine == nil || f.job().Ack == nil { t.Fatal("device ack not saved") } mini, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token}) must(t, err) miniJSON, _ := json.Marshal(mini) if strings.Contains(string(miniJSON), "secret") || mini["voice"] != nil { t.Fatal("credentials exposed to mini program") } } func TestBLEVoiceFailurePreservesBindingAndNeverReregisters(t *testing.T) { f := serverFixture(t) f.registrar.err = errors.New("provider response lost") token := f.bleToken() for i := 0; i < 2; i++ { code, envelope := f.deviceCall("ble-bind", BLEBindInput{Token: token}, uuid.NewString(), true) if code != 502 || envelope["code"] != "VOICE_REGISTRATION_FAILED" || envelope["ok"] != false { t.Fatalf("expected explicit registration error, status=%d", code) } } if f.registrar.calls.Load() != 1 { t.Fatal("uncertain provider registration was repeated") } mini, err := f.s.BLEStatus(f.ctx, f.user, BLEBindInput{Token: token}) must(t, err) if mini["status"] != "success" { t.Fatal("registration failure erased completed binding") } } func TestBLEVoiceWaitsForConcurrentWorkerAndReturnsHTTPFields(t *testing.T) { f := serverFixture(t) token := f.bleToken() _, err := f.s.commitBLEBinding(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) f.refresh() f.registrar.started, f.registrar.release = make(chan struct{}), make(chan struct{}) done := make(chan error, 1) go func() { done <- f.s.ProcessActivation(f.ctx, f.event()) }() select { case <-f.registrar.started: case <-time.After(5 * time.Second): t.Fatal("provider not started") } // A short-lived HTTP caller times out without cancelling another worker. ctx, cancel := context.WithTimeout(f.ctx, 100*time.Millisecond) _, err = f.s.BindBLE(ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) cancel() var apiErr *Error if !errors.As(err, &apiErr) || apiErr.Code != "VOICE_REGISTRATION_TIMEOUT" { t.Fatalf("expected timeout, got %v", err) } close(f.registrar.release) must(t, <-done) code, envelope := f.deviceCall("ble-bind", BLEBindInput{Token: token}, uuid.NewString(), true) if code != 200 || envelope["ok"] != true { t.Fatalf("binding HTTP retry status %d", code) } data := envelope["data"].(map[string]any) for _, key := range []string{"instance_id", "product_key", "device_name", "device_secret", "RTCAppID"} { if value, ok := data[key].(string); !ok || value == "" { t.Fatalf("missing direct field %s", key) } } if len(data) != 6 || f.registrar.calls.Load() != 1 { t.Fatal("wrong response shape or duplicate provider call") } } func TestBLEVoiceUnboundDuringRegistrationReturnsNoCredentials(t *testing.T) { f := serverFixture(t) token := f.bleToken() f.registrar.started, f.registrar.release = make(chan struct{}), make(chan struct{}) done := make(chan error, 1) go func() { out, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) if out != nil { done <- errors.New("returned credentials after unbind") return } done <- err }() select { case <-f.registrar.started: case <-time.After(5 * time.Second): t.Fatal("provider not started") } _, err := f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID) must(t, err) close(f.registrar.release) err = <-done var apiErr *Error if !errors.As(err, &apiErr) || apiErr.Code != "BINDING_CHANGED" { t.Fatalf("expected binding change, got %v", err) } if len(f.job().Secret) == 0 || f.registrar.calls.Load() != 1 { t.Fatal("physical credential was lost") } } func TestBLEVoicePausedReturnsErrorAndResumesSameToken(t *testing.T) { f := serverFixture(t) token := f.bleToken() f.s.options.ActivationEnabled = false _, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) var apiErr *Error if !errors.As(err, &apiErr) || apiErr.Code != "VOICE_REGISTRATION_PAUSED" || f.registrar.calls.Load() != 0 { t.Fatalf("expected paused, got %v", err) } f.s.options.ActivationEnabled = true out, err := f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) if out["RTCAppID"] != "test-app" || f.registrar.calls.Load() != 1 { t.Fatal("resume failed") } } func TestServerVoiceUncertainRegistrationNeverRetries(t *testing.T) { f := serverFixture(t) f.bound() f.registrar.err = errors.New("timeout after request sent") must(t, f.s.ProcessActivation(f.ctx, f.event())) must(t, f.s.ProcessActivation(f.ctx, f.event())) if f.registrar.calls.Load() != 1 || f.job().Status != "RECONCILE_REQUIRED" { t.Fatal("uncertain registration retried") } out, err := f.s.VoiceConfig(f.ctx, f.d) must(t, err) if out["voice_status"] != "reconcile_required" || out["voice"] != nil { t.Fatal("false ready") } } func TestServerVoiceConcurrentWorkerAndUnbind(t *testing.T) { f := serverFixture(t) f.bound() f.registrar.started = make(chan struct{}) f.registrar.release = make(chan struct{}) event := f.event() done := make(chan error, 1) go func() { done <- f.s.ProcessActivation(f.ctx, event) }() select { case <-f.registrar.started: case <-time.After(5 * time.Second): t.Fatal("worker did not register") } must(t, f.s.ProcessActivation(f.ctx, event)) _, err := f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID) must(t, err) close(f.registrar.release) must(t, <-done) if f.registrar.calls.Load() != 1 || len(f.job().Secret) == 0 { t.Fatal("lost credentials or duplicate registration") } f.web = WebSession{} // The old setup web session was intentionally closed by unbind. f.refresh() _, err = f.s.VoiceConfig(f.ctx, f.d) if err == nil { t.Fatal("unbound device received credentials") } token := f.bleToken() _, err = f.s.BindBLE(f.ctx, f.d, uuid.NewString(), BLEBindInput{Token: token}) must(t, err) f.refresh() must(t, f.s.ProcessActivation(f.ctx, f.event())) out, err := f.s.VoiceConfig(f.ctx, f.d) must(t, err) if out["voice_status"] != "ready" || f.registrar.calls.Load() != 1 { t.Fatal("rebind did not reuse physical registration") } } func TestServerVoiceLegacyCredentialMigration(t *testing.T) { f := newFixture(t) f.bound() f.registerSDK() f.s.options.LegacyDeviceRegistration = false must(t, f.s.ProcessActivation(f.ctx, f.event())) out, err := f.s.VoiceConfig(f.ctx, f.d) must(t, err) if out["voice_status"] != "migration_required" || f.registrar.calls.Load() != 0 { t.Fatal("legacy credentials were re-registered") } j := f.job() in := DeviceVoiceCredential{j.Instance, j.Product, j.Name, "fixture-old-device-secret-123456789", "old-rtc-app"} _, err = f.s.ImportVoiceCredential(f.ctx, f.d, in) must(t, err) _, err = f.s.ImportVoiceCredential(f.ctx, f.d, in) must(t, err) in.DeviceSecret = "different-old-secret-123456789" _, err = f.s.ImportVoiceCredential(f.ctx, f.d, in) if err == nil { t.Fatal("conflicting import overwrote credentials") } out, err = f.s.VoiceConfig(f.ctx, f.d) must(t, err) if out["voice_status"] != "ready" || f.registrar.calls.Load() != 0 { t.Fatal("migration did not recover existing credentials") } } func TestServerVoiceRejectUnregisteredImportAndDeviceExecution(t *testing.T) { f := serverFixture(t) f.bound() j := f.job() _, err := f.s.ImportVoiceCredential(f.ctx, f.d, DeviceVoiceCredential{j.Instance, j.Product, j.Name, "fake-secret-1234567890", "app"}) if err == nil { t.Fatal("unregistered device imported credentials") } in := AttemptInput{ActivationID: j.ID, ConfigVersion: j.ConfigVersion, AttemptID: uuid.NewString()} _, err = f.s.Attempt(f.ctx, f.d, uuid.NewString(), requestJSON(in), in) if err == nil { t.Fatal("device registration permit issued") } } func TestServerVoiceCrashFenceExpiresToReconciliation(t *testing.T) { f := serverFixture(t) f.bound() j := f.job() _, err := f.s.pool.Exec(f.ctx, `INSERT INTO device_activation_attempt(id,activation_id,executor,stage,binding_generation,config_version,status,permit_expires_at) VALUES($1::uuid,$2::uuid,'SERVER','SERVER_REGISTER',$3,$4,'CONTACT_POSSIBLE',now()-interval '5 minutes')`, uuid.NewString(), j.ID, j.Generation, j.ConfigVersion) must(t, err) must(t, f.s.ProcessActivation(f.ctx, f.event())) if f.registrar.calls.Load() != 0 || f.job().Status != "RECONCILE_REQUIRED" { t.Fatal("crash fence triggered duplicate registration") } }