feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user