package vowifi import ( "context" "errors" "fmt" "reflect" "strings" "sync" "testing" "time" ) func TestClassifyErrorEAPAuthenticationRejected(t *testing.T) { err := fmt.Errorf("tunnel setup: %w", ErrEAPAuthenticationRejected) if got := classifyError(PhaseTunnelReady, err); got != "eap_authentication_rejected" { t.Fatalf("classifyError = %q", got) } } type fakeEnvironment struct { mu sync.Mutex calls []string failCounts map[string]int blockAt string blocked chan struct{} blockOnce sync.Once identity SIMIdentity akaEvidence AKAEvidence radioSnapshot RadioSnapshot proxy ProxyRoute tunnelEvidence TunnelEvidence imsEvidence IMSEvidence smsEvidence SMSEvidence phoneRecords []PhoneRecord tunnelRequests []TunnelRequest tunnelFailures chan error imsFailures chan error } func newFakeEnvironment() *fakeEnvironment { return &fakeEnvironment{ failCounts: make(map[string]int), blocked: make(chan struct{}), identity: SIMIdentity{ ICCID: "8944100000000000000", IMSI: "234150000000000", IMEI: "860000000000000", HomeMCC: "234", HomeMNC: "15", HomeCountryCode: "GB", }, akaEvidence: AKAEvidence{ Ready: true, Application: "usim", }, radioSnapshot: RadioSnapshot{ CellularDataEnabled: true, OperatingMode: 1, PureAirplanePolicy: false, }, proxy: ProxyRoute{Mode: ProxyModeDirect}, tunnelEvidence: TunnelEvidence{ Established: true, Name: "vowifi0", ResponderAUTH: ResponderAUTHVerified, IKEEncryption: "aes-cbc-128", IKEIntegrity: "hmac-sha2-256", IKEDHGroup: "modp2048", ESPEncryption: "aes-cbc-128", ESPIntegrity: "hmac-sha1-96", }, imsEvidence: IMSEvidence{ Registered: true, RegistrationState: "registered", AssociatedMSISDN: "+447700900123@ims.mnc015.mcc234.3gppnetwork.org", PAssociatedURI: []string{"sip:234150000000000@ims.mnc015.mcc234.3gppnetwork.org"}, Transport: "tcp", LastSIPCode: 200, }, smsEvidence: SMSEvidence{Ready: true}, } } func (environment *fakeEnvironment) record(ctx context.Context, call string) error { environment.mu.Lock() environment.calls = append(environment.calls, call) block := environment.blockAt == call if remaining := environment.failCounts[call]; remaining > 0 { environment.failCounts[call] = remaining - 1 environment.mu.Unlock() return errors.New(call + " failed") } environment.mu.Unlock() if block { environment.blockOnce.Do(func() { close(environment.blocked) }) <-ctx.Done() return ctx.Err() } select { case <-ctx.Done(): return ctx.Err() default: return nil } } func (environment *fakeEnvironment) callsSnapshot() []string { environment.mu.Lock() defer environment.mu.Unlock() return append([]string(nil), environment.calls...) } func (environment *fakeEnvironment) callCount(call string) int { count := 0 for _, recorded := range environment.callsSnapshot() { if recorded == call { count++ } } return count } func (environment *fakeEnvironment) setFailure(call string, count int) { environment.mu.Lock() environment.failCounts[call] = count environment.mu.Unlock() } type fakeSIM struct{ environment *fakeEnvironment } func (fake fakeSIM) ReadIdentity(ctx context.Context, _ string) (SIMIdentity, error) { if err := fake.environment.record(ctx, "sim.identity"); err != nil { return SIMIdentity{}, err } return fake.environment.identity, nil } type fakeAKA struct{ environment *fakeEnvironment } func (fake fakeAKA) CheckReady(ctx context.Context, _ SIMIdentity) (AKAEvidence, error) { if err := fake.environment.record(ctx, "aka.ready"); err != nil { return AKAEvidence{}, err } return fake.environment.akaEvidence, nil } func (fake fakeAKA) Authenticate(ctx context.Context, _ SIMIdentity, _ AKAChallenge) (AKAResult, error) { if err := fake.environment.record(ctx, "aka.authenticate"); err != nil { return AKAResult{}, err } return AKAResult{ RES: []byte{0x01, 0x02, 0x03, 0x04}, CK: make([]byte, 16), IK: make([]byte, 16), }, nil } type fakeRadio struct{ environment *fakeEnvironment } func (fake fakeRadio) Snapshot(ctx context.Context, _ string) (RadioSnapshot, error) { if err := fake.environment.record(ctx, "radio.snapshot"); err != nil { return RadioSnapshot{}, err } return fake.environment.radioSnapshot, nil } func (fake fakeRadio) StopCellularData(ctx context.Context, _ string) error { return fake.environment.record(ctx, "radio.stop_data") } func (fake fakeRadio) EnterVoWiFiRFOff(ctx context.Context, _ string) error { return fake.environment.record(ctx, "radio.rf_off") } func (fake fakeRadio) Restore(ctx context.Context, _ string, _ RadioSnapshot) error { return fake.environment.record(ctx, "radio.restore") } type fakeProxy struct{ environment *fakeEnvironment } func (fake fakeProxy) Resolve(ctx context.Context, _ ProxyRequest) (ProxyRoute, error) { if err := fake.environment.record(ctx, "proxy.resolve"); err != nil { return ProxyRoute{}, err } return fake.environment.proxy, nil } type fakeTunnelProvider struct{ environment *fakeEnvironment } func (fake fakeTunnelProvider) Start(ctx context.Context, request TunnelRequest) (TunnelSession, error) { if err := fake.environment.record(ctx, "tunnel.start"); err != nil { return nil, err } fake.environment.mu.Lock() fake.environment.tunnelRequests = append(fake.environment.tunnelRequests, request) fake.environment.mu.Unlock() return &fakeTunnelSession{environment: fake.environment}, nil } type fakeTunnelSession struct{ environment *fakeEnvironment } func (fake *fakeTunnelSession) Evidence() TunnelEvidence { _ = fake.environment.record(context.Background(), "tunnel.evidence") return fake.environment.tunnelEvidence } func (fake *fakeTunnelSession) Close(ctx context.Context) error { return fake.environment.record(ctx, "tunnel.close") } func (fake *fakeTunnelSession) Failures() <-chan error { return fake.environment.tunnelFailures } type fakeIMSProvider struct{ environment *fakeEnvironment } func (fake fakeIMSProvider) Start(ctx context.Context, _ IMSRequest) (IMSSession, error) { if err := fake.environment.record(ctx, "ims.start"); err != nil { return nil, err } return &fakeIMSSession{environment: fake.environment}, nil } type fakeIMSSession struct{ environment *fakeEnvironment } func (fake *fakeIMSSession) Evidence() IMSEvidence { _ = fake.environment.record(context.Background(), "ims.evidence") return fake.environment.imsEvidence } func (fake *fakeIMSSession) EnableSMS(ctx context.Context) (SMSEvidence, error) { if err := fake.environment.record(ctx, "ims.sms"); err != nil { return SMSEvidence{}, err } return fake.environment.smsEvidence, nil } func (fake *fakeIMSSession) Close(ctx context.Context) error { return fake.environment.record(ctx, "ims.close") } func (fake *fakeIMSSession) Failures() <-chan error { return fake.environment.imsFailures } type fakePhones struct{ environment *fakeEnvironment } func (fake fakePhones) SaveAssociatedNumber(ctx context.Context, record PhoneRecord) error { if err := fake.environment.record(ctx, "phone.save"); err != nil { return err } fake.environment.mu.Lock() fake.environment.phoneRecords = append(fake.environment.phoneRecords, record) fake.environment.mu.Unlock() return nil } func newTestOrchestrator(t *testing.T, environment *fakeEnvironment, allowMissingAUTH bool) *Orchestrator { t.Helper() return newTestOrchestratorWithOptions(t, environment, Options{ DeviceID: "EC20", AllowMissingResponderAUTH: allowMissingAUTH, CleanupTimeout: time.Second, }) } func newTestOrchestratorWithOptions( t *testing.T, environment *fakeEnvironment, options Options, ) *Orchestrator { t.Helper() orchestrator, err := New(Dependencies{ SIM: fakeSIM{environment}, AKA: fakeAKA{environment}, Radio: fakeRadio{environment}, Proxy: fakeProxy{environment}, Tunnel: fakeTunnelProvider{environment}, IMS: fakeIMSProvider{environment}, Phones: fakePhones{environment}, }, options) if err != nil { t.Fatalf("New() error = %v", err) } return orchestrator } func TestEnableKeepsIMSAndNumberWhenSMSCapabilityIsOptional(t *testing.T) { environment := newFakeEnvironment() environment.setFailure("ims.sms", 1) orchestrator := newTestOrchestratorWithOptions(t, environment, Options{ DeviceID: "EC20", AllowIMSWithoutSMS: true, CleanupTimeout: time.Second, }) state, err := orchestrator.Enable(context.Background()) if err != nil { t.Fatalf("Enable() error = %v", err) } if state.Phase != PhaseIMSReady || !state.Active || !state.TunnelReady || !state.IMSReady || state.SMSReady { t.Fatalf("Enable() state = %+v", state) } if state.PhoneNumber != "+447700900123" { t.Fatalf("phone number = %q", state.PhoneNumber) } if state.LastReason != "ims_registered_sms_unavailable" || len(state.Warnings) == 0 { t.Fatalf("optional SMS evidence = %+v", state) } if environment.callCount("ims.close") != 0 || environment.callCount("tunnel.close") != 0 { t.Fatal("optional SMS failure tore down a valid IMS registration") } } func TestEnableUsesEvidenceBackedOrderAndDisableRollsBackInReverse(t *testing.T) { environment := newFakeEnvironment() orchestrator := newTestOrchestrator(t, environment, false) state, err := orchestrator.Enable(context.Background()) if err != nil { t.Fatalf("Enable() error = %v", err) } if state.Phase != PhaseSMSReady || !state.Enabled || !state.Active || !state.SIMReady || !state.AccessReady || !state.TunnelReady || !state.IMSReady || !state.SMSReady { t.Fatalf("Enable() state = %+v", state) } if state.PhoneNumber != "+447700900123" || state.PhoneNumberSource != PhoneSourceAssociatedMSISDN { t.Fatalf("phone projection = %q (%q)", state.PhoneNumber, state.PhoneNumberSource) } if state.Security.ResponderAUTH != ResponderAUTHVerified || state.Security.HighRisk { t.Fatalf("security audit = %+v", state.Security) } if state.PureAirplanePolicy { t.Fatal("VoWiFi RF off must not enable the independent pure-airplane policy") } wantEnableCalls := []string{ "sim.identity", "aka.ready", "radio.snapshot", "radio.rf_off", "radio.stop_data", "proxy.resolve", "tunnel.start", "tunnel.evidence", "ims.start", "ims.evidence", "phone.save", "ims.sms", } if calls := environment.callsSnapshot(); !reflect.DeepEqual(calls, wantEnableCalls) { t.Fatalf("enable calls = %#v, want %#v", calls, wantEnableCalls) } if len(environment.tunnelRequests) != 1 { t.Fatalf("tunnel request count = %d", len(environment.tunnelRequests)) } request := environment.tunnelRequests[0] if request.EPDG != "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org" { t.Fatalf("EPDG = %q", request.EPDG) } if request.Proxy.Mode != ProxyModeDirect || request.Security.AllowMissingResponderAUTH { t.Fatalf("tunnel request = %+v", request) } state, err = orchestrator.Disable(context.Background()) if err != nil { t.Fatalf("Disable() error = %v", err) } if state.Phase != PhaseIdle || state.Enabled || state.Active || state.TunnelReady || state.IMSReady || state.SMSReady { t.Fatalf("Disable() state = %+v", state) } if state.PhoneNumber != "+447700900123" { t.Fatal("disabling the runtime must not erase the ICCID-associated number projection") } calls := environment.callsSnapshot() wantCleanup := []string{"ims.close", "tunnel.close", "radio.restore"} if !reflect.DeepEqual(calls[len(calls)-len(wantCleanup):], wantCleanup) { t.Fatalf("cleanup tail = %#v, want %#v", calls, wantCleanup) } } func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) { tests := []struct { name string failCall string mutate func(*fakeEnvironment) wantError error wantCleanupTail []string }{ {name: "identity", failCall: "sim.identity"}, {name: "aka", failCall: "aka.ready"}, {name: "radio snapshot", failCall: "radio.snapshot"}, { name: "stop data can partially mutate", failCall: "radio.stop_data", wantCleanupTail: []string{"radio.restore"}, }, { name: "rf off", failCall: "radio.rf_off", wantCleanupTail: []string{"radio.restore"}, }, { name: "proxy", failCall: "proxy.resolve", wantCleanupTail: []string{"radio.restore"}, }, { name: "tunnel start", failCall: "tunnel.start", wantCleanupTail: []string{"radio.restore"}, }, { name: "tunnel evidence", mutate: func(environment *fakeEnvironment) { environment.tunnelEvidence.Established = false environment.tunnelEvidence.ResponderAUTH = ResponderAUTHUnknown }, wantError: ErrTunnelNotEstablished, wantCleanupTail: []string{"tunnel.close", "radio.restore"}, }, { name: "IMS start", failCall: "ims.start", wantCleanupTail: []string{"tunnel.close", "radio.restore"}, }, { name: "IMS registration evidence", mutate: func(environment *fakeEnvironment) { environment.imsEvidence.Registered = false }, wantError: ErrIMSNotRegistered, wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, }, { name: "SMS activation", failCall: "ims.sms", wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, }, { name: "SMS evidence", mutate: func(environment *fakeEnvironment) { environment.smsEvidence.Ready = false }, wantError: ErrSMSNotReady, wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { environment := newFakeEnvironment() if test.failCall != "" { environment.setFailure(test.failCall, 1) } if test.mutate != nil { test.mutate(environment) } orchestrator := newTestOrchestrator(t, environment, false) state, err := orchestrator.Enable(context.Background()) if err == nil { t.Fatal("Enable() unexpectedly succeeded") } if test.wantError != nil && !errors.Is(err, test.wantError) { t.Fatalf("Enable() error = %v, want errors.Is(%v)", err, test.wantError) } if state.Phase != PhaseFailed || state.Active || state.TunnelReady || state.IMSReady || state.SMSReady { t.Fatalf("failed state = %+v", state) } if len(test.wantCleanupTail) > 0 { calls := environment.callsSnapshot() if len(calls) < len(test.wantCleanupTail) { t.Fatalf("calls = %#v", calls) } tail := calls[len(calls)-len(test.wantCleanupTail):] if !reflect.DeepEqual(tail, test.wantCleanupTail) { t.Fatalf("cleanup tail = %#v, want %#v", tail, test.wantCleanupTail) } } }) } } func TestResponderAUTHPolicyIsStrictByDefaultAndAuditsExplicitCompatibility(t *testing.T) { t.Run("strict", func(t *testing.T) { environment := newFakeEnvironment() environment.tunnelEvidence.ResponderAUTH = ResponderAUTHMissing orchestrator := newTestOrchestrator(t, environment, false) state, err := orchestrator.Enable(context.Background()) if !errors.Is(err, ErrResponderAUTHRequired) { t.Fatalf("Enable() error = %v", err) } if state.Phase != PhaseFailed || state.Security.HighRisk || state.Security.CompatibilityOverride { t.Fatalf("strict security state = %+v", state.Security) } }) t.Run("explicit compatibility", func(t *testing.T) { environment := newFakeEnvironment() environment.tunnelEvidence.ResponderAUTH = ResponderAUTHMissing orchestrator := newTestOrchestrator(t, environment, true) state, err := orchestrator.Enable(context.Background()) if err != nil { t.Fatalf("Enable() error = %v", err) } if state.Phase != PhaseSMSReady || !state.Security.HighRisk || !state.Security.CompatibilityOverride || state.Security.Level != AuditLevelHigh || state.Security.Code != AuditCodeMissingResponderAUTH { t.Fatalf("compatibility security state = %+v", state.Security) } if !environment.tunnelRequests[0].Security.AllowMissingResponderAUTH { t.Fatal("explicit compatibility policy was not passed to the tunnel provider") } }) t.Run("invalid is never compatible", func(t *testing.T) { environment := newFakeEnvironment() environment.tunnelEvidence.ResponderAUTH = ResponderAUTHInvalid orchestrator := newTestOrchestrator(t, environment, true) state, err := orchestrator.Enable(context.Background()) if !errors.Is(err, ErrResponderAUTHRequired) || state.Phase != PhaseFailed { t.Fatalf("Enable() = (%+v, %v)", state, err) } }) } func TestPhoneNumberIsNeverInferredFromIMSI(t *testing.T) { environment := newFakeEnvironment() environment.identity.IMSI = "234159999999999" environment.imsEvidence.AssociatedMSISDN = "" environment.imsEvidence.PAssociatedURI = []string{ "sip:234159999999999@ims.mnc015.mcc234.3gppnetwork.org", } orchestrator := newTestOrchestrator(t, environment, false) state, err := orchestrator.Enable(context.Background()) if err != nil { t.Fatalf("Enable() error = %v", err) } if state.PhoneNumber != "" || environment.callCount("phone.save") != 0 { t.Fatalf("number was inferred: state=%+v records=%+v", state, environment.phoneRecords) } if len(state.Warnings) != 1 || !strings.Contains(state.Warnings[0], "not inferred from IMSI") { t.Fatalf("warnings = %#v", state.Warnings) } } func TestPhoneStoreFailureDoesNotMisreportOrTearDownWorkingIMS(t *testing.T) { environment := newFakeEnvironment() environment.setFailure("phone.save", 1) orchestrator := newTestOrchestrator(t, environment, false) state, err := orchestrator.Enable(context.Background()) if err != nil { t.Fatalf("Enable() error = %v", err) } if state.Phase != PhaseSMSReady || !state.IMSReady || state.PhoneNumber != "" { t.Fatalf("state = %+v", state) } if len(state.Warnings) != 1 || !strings.Contains(state.Warnings[0], "could not be persisted") { t.Fatalf("warnings = %#v", state.Warnings) } } func TestDisableCancelsAnInFlightEnableAndRestoresRadio(t *testing.T) { environment := newFakeEnvironment() environment.blockAt = "tunnel.start" orchestrator := newTestOrchestrator(t, environment, false) enableResult := make(chan error, 1) go func() { _, err := orchestrator.Enable(context.Background()) enableResult <- err }() select { case <-environment.blocked: case <-time.After(2 * time.Second): t.Fatal("Enable() did not reach blocking tunnel provider") } disableContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() state, err := orchestrator.Disable(disableContext) if err != nil { t.Fatalf("Disable() error = %v", err) } if state.Phase != PhaseIdle || state.Enabled || state.Active { t.Fatalf("Disable() state = %+v", state) } select { case err := <-enableResult: if !errors.Is(err, context.Canceled) { t.Fatalf("Enable() error = %v, want context.Canceled", err) } case <-time.After(2 * time.Second): t.Fatal("Enable() did not exit after Disable() cancellation") } if environment.callCount("radio.restore") != 1 { t.Fatalf("radio.restore count = %d", environment.callCount("radio.restore")) } } func TestConcurrentEnableStartsOnlyOneRuntime(t *testing.T) { environment := newFakeEnvironment() orchestrator := newTestOrchestrator(t, environment, false) const goroutines = 24 start := make(chan struct{}) results := make(chan error, goroutines) var group sync.WaitGroup for index := 0; index < goroutines; index++ { group.Add(1) go func() { defer group.Done() <-start _, err := orchestrator.Enable(context.Background()) results <- err }() } close(start) group.Wait() close(results) successes := 0 alreadyEnabled := 0 for err := range results { switch { case err == nil: successes++ case errors.Is(err, ErrAlreadyEnabled): alreadyEnabled++ default: t.Fatalf("unexpected Enable() error = %v", err) } } if successes != 1 || alreadyEnabled != goroutines-1 { t.Fatalf("successes=%d alreadyEnabled=%d", successes, alreadyEnabled) } if environment.callCount("tunnel.start") != 1 { t.Fatalf("tunnel.start count = %d", environment.callCount("tunnel.start")) } } func TestRetryAfterFailureCreatesANewAttempt(t *testing.T) { environment := newFakeEnvironment() environment.setFailure("tunnel.start", 1) orchestrator := newTestOrchestrator(t, environment, false) first, err := orchestrator.Enable(context.Background()) if err == nil || first.Phase != PhaseFailed || first.Attempt != 1 { t.Fatalf("first Enable() = (%+v, %v)", first, err) } second, err := orchestrator.Retry(context.Background()) if err != nil { t.Fatalf("Retry() error = %v", err) } if second.Phase != PhaseSMSReady || second.Attempt != 2 { t.Fatalf("Retry() state = %+v", second) } if environment.callCount("tunnel.start") != 2 { t.Fatalf("tunnel.start count = %d", environment.callCount("tunnel.start")) } } func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) { environment := newFakeEnvironment() orchestrator := newTestOrchestrator(t, environment, false) if _, err := orchestrator.Enable(context.Background()); err != nil { t.Fatal(err) } state, err := orchestrator.Reconnect(context.Background()) if err != nil { t.Fatalf("Reconnect() error = %v", err) } if state.Phase != PhaseSMSReady || state.Attempt != 2 { t.Fatalf("Reconnect() state = %+v", state) } if environment.callCount("tunnel.start") != 2 || environment.callCount("tunnel.close") != 1 || environment.callCount("radio.restore") != 1 { t.Fatalf("calls = %#v", environment.callsSnapshot()) } } // A non-fatal teardown error (for example the network rejecting SIP // deregistration during IMS close) must not stop a reconnect from rebuilding // the runtime; Disable still releases the local IMS, tunnel, and radio layers. func TestReconnectToleratesCleanupFailureAndRebuilds(t *testing.T) { environment := newFakeEnvironment() orchestrator := newTestOrchestrator(t, environment, false) if _, err := orchestrator.Enable(context.Background()); err != nil { t.Fatal(err) } environment.setFailure("ims.close", 1) state, err := orchestrator.Reconnect(context.Background()) if err != nil { t.Fatalf("Reconnect() error = %v", err) } if state.Phase != PhaseSMSReady || state.Attempt != 2 { t.Fatalf("Reconnect() state = %+v", state) } if environment.callCount("ims.close") != 1 || environment.callCount("tunnel.close") != 1 || environment.callCount("radio.restore") != 1 || environment.callCount("tunnel.start") != 2 { t.Fatalf("calls = %#v", environment.callsSnapshot()) } } func TestRuntimeTunnelFailureRevokesReadinessAndCleansEveryLayer(t *testing.T) { environment := newFakeEnvironment() environment.tunnelFailures = make(chan error, 1) orchestrator := newTestOrchestrator(t, environment, false) if _, err := orchestrator.Enable(context.Background()); err != nil { t.Fatal(err) } environment.tunnelFailures <- errors.New("ESP relay stopped") deadline := time.Now().Add(2 * time.Second) for { state := orchestrator.State() if state.Phase == PhaseFailed { if state.Active || state.TunnelReady || state.IMSReady || state.SMSReady { t.Fatalf("stale runtime readiness survived failure: %+v", state) } if !state.Enabled || state.LastErrorClass != "tunnel_runtime" || state.LastReason != "runtime_tunnel_failed" || !strings.Contains(state.LastError, "ESP relay stopped") { t.Fatalf("runtime failure evidence = %+v", state) } break } if time.Now().After(deadline) { t.Fatalf("timed out waiting for runtime failure; state = %+v", state) } time.Sleep(10 * time.Millisecond) } calls := environment.callsSnapshot() wantTail := []string{"ims.close", "tunnel.close", "radio.restore"} if len(calls) < len(wantTail) || !reflect.DeepEqual(calls[len(calls)-len(wantTail):], wantTail) { t.Fatalf("runtime failure cleanup tail = %#v", calls) } } func TestRuntimeIMSFailureRevokesRegistrationEvidence(t *testing.T) { environment := newFakeEnvironment() environment.imsFailures = make(chan error, 1) orchestrator := newTestOrchestrator(t, environment, false) if _, err := orchestrator.Enable(context.Background()); err != nil { t.Fatal(err) } environment.imsFailures <- errors.New("registration refresh failed") deadline := time.Now().Add(2 * time.Second) for { state := orchestrator.State() if state.Phase == PhaseFailed { if state.TunnelReady || state.IMSReady || state.SMSReady || state.LastErrorClass != "ims_runtime" || state.LastReason != "runtime_ims_failed" { t.Fatalf("IMS runtime failure evidence = %+v", state) } return } if time.Now().After(deadline) { t.Fatalf("timed out waiting for IMS runtime failure; state = %+v", state) } time.Sleep(10 * time.Millisecond) } } func TestSubscriptionPublishesOrderedEvidencePhases(t *testing.T) { environment := newFakeEnvironment() orchestrator := newTestOrchestrator(t, environment, false) updates, unsubscribe := orchestrator.Subscribe(32) defer unsubscribe() if _, err := orchestrator.Enable(context.Background()); err != nil { t.Fatal(err) } var phases []Phase deadline := time.After(2 * time.Second) for { select { case state := <-updates: if len(phases) == 0 || phases[len(phases)-1] != state.Phase { phases = append(phases, state.Phase) } if state.Phase == PhaseSMSReady { want := []Phase{ PhaseIdle, PhaseSIMReady, PhaseAccessReady, PhaseTunnelReady, PhaseIMSReady, PhaseSMSReady, } if !reflect.DeepEqual(phases, want) { t.Fatalf("phases = %#v, want %#v", phases, want) } return } case <-deadline: t.Fatalf("timed out waiting for phases; got %#v", phases) } } } func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) { environment := newFakeEnvironment() environment.setFailure("ims.sms", 1) environment.setFailure("ims.close", 1) environment.setFailure("tunnel.close", 1) environment.setFailure("radio.restore", 1) orchestrator := newTestOrchestrator(t, environment, false) state, err := orchestrator.Enable(context.Background()) if err == nil { t.Fatal("Enable() unexpectedly succeeded") } if len(state.CleanupErrors) != 3 { t.Fatalf("cleanup errors = %#v", state.CleanupErrors) } calls := environment.callsSnapshot() wantTail := []string{"ims.close", "tunnel.close", "radio.restore"} if !reflect.DeepEqual(calls[len(calls)-3:], wantTail) { t.Fatalf("cleanup tail = %#v", calls[len(calls)-3:]) } for _, text := range []string{"close IMS", "close tunnel", "restore radio"} { if !strings.Contains(err.Error(), text) { t.Fatalf("error %q does not contain %q", err, text) } } } func TestDisableCleanupWarningStillSettlesIdle(t *testing.T) { environment := newFakeEnvironment() orchestrator := newTestOrchestrator(t, environment, false) if _, err := orchestrator.Enable(context.Background()); err != nil { t.Fatalf("Enable() error = %v", err) } environment.setFailure("ims.close", 1) state, err := orchestrator.Disable(context.Background()) if !errors.Is(err, ErrCleanupIncomplete) { t.Fatalf("Disable() error = %v, want ErrCleanupIncomplete", err) } if state.Phase != PhaseIdle || state.Enabled || state.Active || state.SIMReady || state.AccessReady || state.TunnelReady || state.IMSReady || state.SMSReady { t.Fatalf("Disable() warning state = %+v", state) } if state.LastErrorClass != "cleanup_warning" || state.LastReason != "disabled_with_cleanup_errors" || len(state.CleanupErrors) != 1 { t.Fatalf("Disable() warning evidence = %+v", state) } } func TestNewRejectsMissingProvidersAndInvalidOptions(t *testing.T) { environment := newFakeEnvironment() dependencies := Dependencies{ SIM: fakeSIM{environment}, AKA: fakeAKA{environment}, Radio: fakeRadio{environment}, Proxy: fakeProxy{environment}, Tunnel: fakeTunnelProvider{environment}, IMS: fakeIMSProvider{environment}, Phones: fakePhones{environment}, } if _, err := New(Dependencies{}, Options{DeviceID: "EC20"}); err == nil { t.Fatal("New() accepted missing providers") } if _, err := New(dependencies, Options{}); err == nil { t.Fatal("New() accepted empty device ID") } }