Files

296 lines
10 KiB
Go
Raw Permalink Normal View History

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")
}
}