296 lines
10 KiB
Go
296 lines
10 KiB
Go
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")
|
||
|
|
}
|
||
|
|
}
|