mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
401 lines
13 KiB
Go
401 lines
13 KiB
Go
package device
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"vocat/internal/modem"
|
|
)
|
|
|
|
// tlv builds one BER-TLV element from a (possibly multi-byte) tag and a body
|
|
// assembled from the given parts.
|
|
func tlv(tag []byte, parts ...[]byte) []byte {
|
|
var body []byte
|
|
for _, part := range parts {
|
|
body = append(body, part...)
|
|
}
|
|
out := append([]byte(nil), tag...)
|
|
switch {
|
|
case len(body) < 0x80:
|
|
out = append(out, byte(len(body)))
|
|
case len(body) < 0x100:
|
|
out = append(out, 0x81, byte(len(body)))
|
|
default:
|
|
out = append(out, 0x82, byte(len(body)>>8), byte(len(body)))
|
|
}
|
|
return append(out, body...)
|
|
}
|
|
|
|
func esimTestProfile(t *testing.T, iccidDigits, provider, name string, state byte) []byte {
|
|
t.Helper()
|
|
bcd, err := encodeICCID(iccidDigits)
|
|
if err != nil {
|
|
t.Fatalf("encodeICCID: %v", err)
|
|
}
|
|
aid, _ := hex.DecodeString("A0000005591010FFFFFFFF8900001000")
|
|
// Icon deliberately contains 0x5A and 0xE3 bytes to prove the parser never
|
|
// descends into primitive (non-constructed) leaves.
|
|
icon := []byte{0x89, 0x50, 0x4E, 0x47, 0x5A, 0xE3, 0x05, 0x9F, 0x70, 0x01}
|
|
return tlv([]byte{0xE3},
|
|
tlv([]byte{0x5A}, bcd),
|
|
tlv([]byte{0x4F}, aid),
|
|
tlv([]byte{0x9F, 0x70}, []byte{state}),
|
|
tlv([]byte{0x91}, []byte(provider)),
|
|
tlv([]byte{0x92}, []byte(name)),
|
|
tlv([]byte{0x94}, icon),
|
|
)
|
|
}
|
|
|
|
func TestParseProfilesInfoRealShape(t *testing.T) {
|
|
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
|
body := tlv([]byte{0xA0},
|
|
esimTestProfile(t, "89441000400128014257", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
|
esimTestProfile(t, "89441000430011604140", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
|
esimTestProfile(t, "89852351225001058508", "Webbing", "WEBBING", 0x00),
|
|
)
|
|
payload := tlv([]byte{0xBF, 0x2D}, body)
|
|
|
|
profiles := parseProfilesInfo(payload)
|
|
if len(profiles) != 3 {
|
|
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
|
|
}
|
|
if profiles[0].ICCID != "89441000400128014257" || profiles[0].State != 0 {
|
|
t.Fatalf("profile[0] = %#v", profiles[0])
|
|
}
|
|
if profiles[1].ICCID != "89441000430011604140" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
|
|
t.Fatalf("profile[1] = %#v", profiles[1])
|
|
}
|
|
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
|
|
t.Fatalf("profile[2] = %#v", profiles[2])
|
|
}
|
|
info := &EsimInfo{Profiles: profiles}
|
|
enabled := info.EnabledProfile()
|
|
if enabled == nil || enabled.Name != "Vodafone UK eSIM" {
|
|
t.Fatalf("enabled profile = %#v", enabled)
|
|
}
|
|
}
|
|
|
|
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
|
real := esimTestProfile(t, "89441000400316048687", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
|
duplicate := esimTestProfile(t, "89441000400316048687", "Duplicate", "Duplicate", 0x00)
|
|
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
|
empty := tlv([]byte{0xE3})
|
|
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
|
|
|
|
profiles := parseProfilesInfo(payload)
|
|
if len(profiles) != 1 {
|
|
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
|
|
}
|
|
if profiles[0].ICCID != "89441000400316048687" || profiles[0].Name != "Vodafone UK eSIM" {
|
|
t.Fatalf("profile = %#v", profiles[0])
|
|
}
|
|
}
|
|
|
|
func TestICCIDRoundTrip(t *testing.T) {
|
|
for _, digits := range []string{"89441000400128014257", "8985235122500105850", "1"} {
|
|
bcd, err := encodeICCID(digits)
|
|
if err != nil {
|
|
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
|
}
|
|
if len(bcd) != 10 {
|
|
t.Fatalf("encodeICCID(%q) length = %d, want fixed 10 octets", digits, len(bcd))
|
|
}
|
|
if got := decodeICCID(bcd); got != digits {
|
|
t.Fatalf("round trip %q -> %q", digits, got)
|
|
}
|
|
}
|
|
if _, err := encodeICCID("894410004001280142571"); err == nil {
|
|
t.Fatal("21-digit ICCID was accepted")
|
|
}
|
|
}
|
|
|
|
func TestEnableProfileRequestPads18DigitICCIDToTenOctets(t *testing.T) {
|
|
request, err := buildEnableProfileRequest("894921007608519523")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3111A00C5A0A989412006780155932FF8101FF" {
|
|
t.Fatalf("EnableProfile request = %s", got)
|
|
}
|
|
}
|
|
|
|
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
|
request, err := buildDeleteProfileRequest("89441000400128014257")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100041082102475" {
|
|
t.Fatalf("DeleteProfile request = %s", got)
|
|
}
|
|
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
|
if !ok || result != 0 {
|
|
t.Fatalf("DeleteProfile result = (%d, %v)", result, ok)
|
|
}
|
|
activeResponse := []byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x02}
|
|
if err := deleteProfileResponseError(2, activeResponse); !errors.Is(err, ErrESIMDeleteProfileNotDisabled) {
|
|
t.Fatalf("DeleteProfile result 2 error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetNicknameRequestAndResult(t *testing.T) {
|
|
request, err := buildSetNicknameRequest("89441000400128014257", "Test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100041082102475900454657374" {
|
|
t.Fatalf("SetNickname request = %s", got)
|
|
}
|
|
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
|
if !ok || result != 0 {
|
|
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
|
|
}
|
|
if _, err := buildSetNicknameRequest("89441000400128014257", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
|
|
t.Fatalf("long nickname error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDisableProfileRequestAndResult(t *testing.T) {
|
|
request, err := buildDisableProfileRequest("89441000400128014257")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000410821024758101FF" {
|
|
t.Fatalf("DisableProfile request = %s", got)
|
|
}
|
|
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
|
if !ok || result != 0 {
|
|
t.Fatalf("DisableProfile result = (%d, %v)", result, ok)
|
|
}
|
|
busyResponse := []byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x05}
|
|
if err := disableProfileResponseError(5, busyResponse); !errors.Is(err, ErrESIMDisableCATBusy) {
|
|
t.Fatalf("DisableProfile result 5 error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnableProfileResultErrors(t *testing.T) {
|
|
undefinedResponse := []byte{0xBF, 0x31, 0x03, 0x80, 0x01, 0x7F}
|
|
result, ok := enableProfileResult(undefinedResponse)
|
|
if !ok || result != 0x7F {
|
|
t.Fatalf("EnableProfile result = (%d, %v)", result, ok)
|
|
}
|
|
if err := enableProfileResponseError(byte(result), undefinedResponse); !errors.Is(err, ErrESIMEnableUndefined) {
|
|
t.Fatalf("EnableProfile undefinedError = %v", err)
|
|
}
|
|
policyResponse := []byte{0xBF, 0x31, 0x03, 0x80, 0x01, 0x03}
|
|
if err := enableProfileResponseError(3, policyResponse); !errors.Is(err, ErrESIMEnableDisallowedPolicy) {
|
|
t.Fatalf("EnableProfile policy error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
|
|
client := &transcriptClient{steps: []clientStep{{
|
|
command: "AT+CCID",
|
|
response: okResponse("+CCID: 89492026266006792824F"),
|
|
}}}
|
|
manager, id := newStartedTestManager(t, client)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := manager.verifySwitchedICCID(ctx, id, "89492026266006792824"); err != nil {
|
|
t.Fatalf("verifySwitchedICCID: %v", err)
|
|
}
|
|
client.assertDone(t)
|
|
}
|
|
|
|
func TestEUMManufacturerForWatchData(t *testing.T) {
|
|
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
|
|
t.Fatalf("manufacturer = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEUMManufacturerForEastcompeace(t *testing.T) {
|
|
if got := eumManufacturerForEID("89086030202200000025000015085962"); got != "Eastcompeace Technology Co., Ltd." {
|
|
t.Fatalf("manufacturer = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEUMManufacturerForModernThalesPrefix(t *testing.T) {
|
|
if got := eumManufacturerForEID("89033023427100000000056707807049"); got != "Thales DIS France SAS" {
|
|
t.Fatalf("manufacturer = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEuiccSASTrimsCardPadding(t *testing.T) {
|
|
payload := derConstruct(0xBF22, derEncode(0x0C, []byte(" SAS-UP-TEST ")))
|
|
if got := euiccSAS(payload); got != "SAS-UP-TEST" {
|
|
t.Fatalf("SAS = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTransientEuiccCMEClassification(t *testing.T) {
|
|
err := fmt.Errorf("select ISD-R: %w", &modem.CommandError{
|
|
Command: `AT+CSIM=42,"01A40400"`,
|
|
Final: "+CME ERROR: 0",
|
|
})
|
|
if !isTransientEuiccCME(err) {
|
|
t.Fatal("wrapped +CME ERROR: 0 must be retryable")
|
|
}
|
|
if isTransientEuiccCME(&modem.CommandError{Final: "+CME ERROR: 13"}) {
|
|
t.Fatal("SIM failure must not be classified as a transient SELECT error")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
|
|
manageChannel := clientStep{
|
|
command: `AT+CSIM=10,"0070000001"`,
|
|
response: okResponse(`+CSIM: 6,"019000"`),
|
|
}
|
|
closeChannel := clientStep{
|
|
command: `AT+CSIM=10,"0070800100"`,
|
|
response: okResponse(`+CSIM: 4,"9000"`),
|
|
}
|
|
selectStep := func(aid, response string) clientStep {
|
|
return clientStep{
|
|
command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid),
|
|
response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, response)),
|
|
}
|
|
}
|
|
|
|
client := &transcriptClient{steps: []clientStep{
|
|
// No eSTK product applet on this card.
|
|
manageChannel,
|
|
selectStep(estkProductAID, "6A82"),
|
|
closeChannel,
|
|
// XeSIM does not expose the standard GSMA ...0100 application.
|
|
manageChannel,
|
|
selectStep(isdRAID, "6A82"),
|
|
closeChannel,
|
|
// Its dedicated ...0177 ISD-R is selectable.
|
|
manageChannel,
|
|
selectStep(xesimISDRAID, "9000"),
|
|
closeChannel,
|
|
}}
|
|
manager, id := newStartedTestManager(t, client)
|
|
|
|
aids := manager.discoverEuiccAIDs(context.Background(), id)
|
|
if len(aids) != 1 || aids[0] != xesimISDRAID {
|
|
t.Fatalf("discovered AIDs = %#v, want XeSIM %s", aids, xesimISDRAID)
|
|
}
|
|
client.assertDone(t)
|
|
}
|
|
|
|
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
|
cause := &modem.CommandError{
|
|
Command: `AT+CSIM=10,"0070000001"`,
|
|
Final: "+CME ERROR: 0",
|
|
}
|
|
err := fmt.Errorf("%w: %v", ErrEUICCChannelStuck, cause)
|
|
if !errors.Is(err, ErrEUICCChannelStuck) {
|
|
t.Fatal("wrapped hot-swap channel failure must retain its sentinel")
|
|
}
|
|
}
|
|
|
|
func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) {
|
|
client := &transcriptClient{steps: []clientStep{
|
|
{
|
|
command: `AT+CSIM=10,"0070000001"`,
|
|
response: okResponse(`+CSIM: 6,"006A81"`),
|
|
},
|
|
{
|
|
command: `AT+CSIM=10,"0070800100"`,
|
|
response: okResponse(`+CSIM: 4,"9000"`),
|
|
},
|
|
{
|
|
command: `AT+CSIM=10,"0070000001"`,
|
|
response: okResponse(`+CSIM: 6,"019000"`),
|
|
},
|
|
{
|
|
command: fmt.Sprintf(
|
|
`AT+CSIM=42,"01A4040010%s"`,
|
|
isdRAID,
|
|
),
|
|
response: okResponse(`+CSIM: 4,"9000"`),
|
|
},
|
|
{
|
|
command: `AT+CSIM=10,"0070800100"`,
|
|
response: okResponse(`+CSIM: 4,"9000"`),
|
|
},
|
|
}}
|
|
manager, id := newStartedTestManager(t, client)
|
|
|
|
manager.lockESIM()
|
|
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
|
|
if err == nil {
|
|
channel.close(context.Background())
|
|
}
|
|
manager.unlockESIM()
|
|
if err != nil {
|
|
t.Fatalf("open eUICC after orphaned channel: %v", err)
|
|
}
|
|
client.assertDone(t)
|
|
}
|
|
|
|
func TestWaitForESIMRecovery(t *testing.T) {
|
|
done := make(chan struct{})
|
|
manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}}
|
|
go func() {
|
|
time.Sleep(10 * time.Millisecond)
|
|
close(done)
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := manager.waitForESIMRecovery(ctx, "dev"); err != nil {
|
|
t.Fatalf("waitForESIMRecovery: %v", err)
|
|
}
|
|
|
|
blocked := make(chan struct{})
|
|
manager.esimRecoveries["blocked"] = blocked
|
|
timeoutContext, cancelTimeout := context.WithTimeout(context.Background(), time.Millisecond)
|
|
defer cancelTimeout()
|
|
if err := manager.waitForESIMRecovery(timeoutContext, "blocked"); err == nil {
|
|
t.Fatal("waitForESIMRecovery must honor caller cancellation")
|
|
}
|
|
}
|
|
|
|
func TestESIMListProfilesReturnsCacheDuringRecovery(t *testing.T) {
|
|
done := make(chan struct{})
|
|
manager := &Manager{
|
|
esimRecoveries: map[string]chan struct{}{"dev": done},
|
|
esimCache: map[string]EsimInfo{
|
|
"dev": {Profiles: []EsimProfile{{ICCID: "old", State: 1}}},
|
|
},
|
|
}
|
|
|
|
info, err := manager.ESIMListProfiles(context.Background(), "dev")
|
|
if err != nil {
|
|
t.Fatalf("ESIMListProfiles during recovery: %v", err)
|
|
}
|
|
if len(info.Profiles) != 1 || info.Profiles[0].ICCID != "old" {
|
|
t.Fatalf("cached profiles = %#v", info.Profiles)
|
|
}
|
|
|
|
// The returned value must not alias the manager cache.
|
|
info.Profiles[0].ICCID = "changed"
|
|
cached, _ := manager.cachedESIMInfo("dev")
|
|
if cached.Profiles[0].ICCID != "old" {
|
|
t.Fatalf("caller mutated cache: %#v", cached.Profiles)
|
|
}
|
|
}
|
|
|
|
func TestMarkCachedProfileEnabled(t *testing.T) {
|
|
manager := &Manager{esimCache: map[string]EsimInfo{
|
|
"dev": {Profiles: []EsimProfile{
|
|
{ICCID: "old", State: 1, StateText: "old state"},
|
|
{ICCID: "target", State: 0, StateText: "target state"},
|
|
}},
|
|
}}
|
|
|
|
manager.markCachedProfileEnabled("dev", "target")
|
|
info, ok := manager.cachedESIMInfo("dev")
|
|
if !ok || info.Profiles[0].State != 0 || info.Profiles[0].StateText != "已禁用" {
|
|
t.Fatalf("old profile state = %#v", info.Profiles[0])
|
|
}
|
|
if info.Profiles[1].State != 1 || info.Profiles[1].StateText != "已启用" {
|
|
t.Fatalf("target profile state = %#v", info.Profiles[1])
|
|
}
|
|
}
|