mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks. - Created `automatic_tasks_test.go` to validate task claiming and deletion behavior. - Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution. - Integrated device and eSIM profile selection for task configuration. - Implemented automatic task scheduling and retry logic in the backend.
601 lines
15 KiB
Go
601 lines
15 KiB
Go
package vowifi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"vocat/internal/modem"
|
|
)
|
|
|
|
type ec20TranscriptStep struct {
|
|
command string
|
|
sensitive bool
|
|
lines []string
|
|
final string
|
|
err error
|
|
}
|
|
|
|
type ec20Transcript struct {
|
|
t *testing.T
|
|
mu sync.Mutex
|
|
steps []ec20TranscriptStep
|
|
next int
|
|
}
|
|
|
|
func TestICCIDIdentifierStripsBCDPadding(t *testing.T) {
|
|
for _, test := range []struct {
|
|
wire string
|
|
want string
|
|
}{
|
|
{wire: "8944110069353447454F", want: "8944110069353447454"},
|
|
{wire: "894921007608519523FF", want: "894921007608519523"},
|
|
} {
|
|
response := modem.Response{Lines: []string{"+QCCID: " + test.wire}}
|
|
if got := iccidIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != test.want {
|
|
t.Fatalf("iccidIdentifier(%q) = %q, want %q", test.wire, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (transcript *ec20Transcript) ExecuteAT(
|
|
_ context.Context,
|
|
_ string,
|
|
command string,
|
|
) (modem.Response, error) {
|
|
return transcript.execute(command, false)
|
|
}
|
|
|
|
func (transcript *ec20Transcript) ExecuteSensitiveAT(
|
|
_ context.Context,
|
|
_ string,
|
|
command string,
|
|
) (modem.Response, error) {
|
|
return transcript.execute(command, true)
|
|
}
|
|
|
|
func (transcript *ec20Transcript) execute(
|
|
command string,
|
|
sensitive bool,
|
|
) (modem.Response, error) {
|
|
transcript.t.Helper()
|
|
transcript.mu.Lock()
|
|
defer transcript.mu.Unlock()
|
|
if transcript.next >= len(transcript.steps) {
|
|
transcript.t.Fatalf("unexpected EC20 command %q", command)
|
|
}
|
|
step := transcript.steps[transcript.next]
|
|
transcript.next++
|
|
if command != step.command {
|
|
transcript.t.Fatalf(
|
|
"EC20 command %d = %q, want %q",
|
|
transcript.next,
|
|
command,
|
|
step.command,
|
|
)
|
|
}
|
|
if sensitive != step.sensitive {
|
|
transcript.t.Fatalf(
|
|
"EC20 command %q sensitive=%v, want %v",
|
|
command,
|
|
sensitive,
|
|
step.sensitive,
|
|
)
|
|
}
|
|
final := step.final
|
|
if final == "" && step.err == nil {
|
|
final = "OK"
|
|
}
|
|
return modem.Response{
|
|
Command: command,
|
|
Lines: append([]string(nil), step.lines...),
|
|
Final: final,
|
|
}, step.err
|
|
}
|
|
|
|
func (transcript *ec20Transcript) assertDone() {
|
|
transcript.t.Helper()
|
|
transcript.mu.Lock()
|
|
defer transcript.mu.Unlock()
|
|
if transcript.next != len(transcript.steps) {
|
|
transcript.t.Fatalf(
|
|
"consumed %d/%d EC20 transcript steps",
|
|
transcript.next,
|
|
len(transcript.steps),
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestEC20AdapterCSIMFallbackSupportsSuccessAndSynchronizationFailure(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
apdu []byte
|
|
wantErr error
|
|
assert func(*testing.T, AKAResult)
|
|
}{
|
|
{
|
|
name: "success",
|
|
apdu: successfulUSIMResponse(),
|
|
assert: func(t *testing.T, result AKAResult) {
|
|
t.Helper()
|
|
if result.SynchronizationFailure {
|
|
t.Fatal("successful result was marked as synchronization failure")
|
|
}
|
|
if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) {
|
|
t.Fatalf("RES = %x", result.RES)
|
|
}
|
|
if len(result.CK) != 16 || len(result.IK) != 16 {
|
|
t.Fatalf("CK/IK lengths = %d/%d", len(result.CK), len(result.IK))
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "synchronization_failure",
|
|
apdu: synchronizationFailureUSIMResponse(),
|
|
assert: func(t *testing.T, result AKAResult) {
|
|
t.Helper()
|
|
if !result.SynchronizationFailure {
|
|
t.Fatal("AUTS result was not marked as synchronization failure")
|
|
}
|
|
if len(result.AUTS) != 14 {
|
|
t.Fatalf("AUTS length = %d", len(result.AUTS))
|
|
}
|
|
if len(result.RES) != 0 || len(result.CK) != 0 || len(result.IK) != 0 {
|
|
t.Fatal("synchronization failure exposed a success vector")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "mac_failure_9862",
|
|
apdu: []byte{0x98, 0x62},
|
|
wantErr: ErrEC20AKAMACFailure,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var challenge AKAChallenge
|
|
for index := range challenge.RAND {
|
|
challenge.RAND[index] = byte(index)
|
|
challenge.AUTN[index] = byte(0xf0 + index)
|
|
}
|
|
authAPDU := buildUSIMAuthenticateAPDU(challenge)
|
|
authCommand := fmt.Sprintf(
|
|
`AT+CSIM=%d,"%s"`,
|
|
len(authAPDU)*2,
|
|
strings.ToUpper(hex.EncodeToString(authAPDU)),
|
|
)
|
|
encodedResponse := strings.ToUpper(hex.EncodeToString(test.apdu))
|
|
|
|
transcript := &ec20Transcript{
|
|
t: t,
|
|
steps: append(
|
|
identityTranscriptSteps("234150123456789"),
|
|
ec20TranscriptStep{
|
|
command: "AT+CCID",
|
|
lines: []string{"+CCID: 8944101234567890123"},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: "AT+CUAD",
|
|
lines: []string{
|
|
`+CUAD: 22,"61094F07A0000000871002"`,
|
|
},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: `AT+CCHO="A0000000871002"`,
|
|
err: errors.New("unsupported"),
|
|
final: "ERROR",
|
|
},
|
|
// The SELECT response requests GET RESPONSE. This is the
|
|
// behavior observed on EC20 basic-channel firmware.
|
|
ec20TranscriptStep{
|
|
command: `AT+CSIM=24,"00A4040407A0000000871002"`,
|
|
lines: []string{`+CSIM: 4,"613A"`},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: `AT+CSIM=10,"00C000003A"`,
|
|
lines: []string{`+CSIM: 4,"9000"`},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: "AT+CCID",
|
|
lines: []string{"+CCID: 8944101234567890123"},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: `AT+CSIM=24,"00A4040407A0000000871002"`,
|
|
lines: []string{`+CSIM: 4,"9000"`},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: authCommand,
|
|
sensitive: true,
|
|
lines: []string{fmt.Sprintf(
|
|
`+CSIM: %d,"%s"`,
|
|
len(encodedResponse),
|
|
encodedResponse,
|
|
)},
|
|
},
|
|
),
|
|
}
|
|
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
|
if err != nil {
|
|
t.Fatalf("ReadIdentity: %v", err)
|
|
}
|
|
evidence, err := adapter.CheckReady(context.Background(), identity)
|
|
if err != nil {
|
|
t.Fatalf("CheckReady: %v", err)
|
|
}
|
|
if !evidence.Ready || evidence.Application != "USIM" {
|
|
t.Fatalf("AKA evidence = %#v", evidence)
|
|
}
|
|
result, err := adapter.Authenticate(
|
|
context.Background(),
|
|
identity,
|
|
challenge,
|
|
)
|
|
if test.wantErr != nil {
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("Authenticate error = %v, want %v", err, test.wantErr)
|
|
}
|
|
transcript.assertDone()
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Authenticate: %v", err)
|
|
}
|
|
test.assert(t, result)
|
|
transcript.assertDone()
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
var challenge AKAChallenge
|
|
for index := range challenge.RAND {
|
|
challenge.RAND[index] = byte(index)
|
|
challenge.AUTN[index] = byte(0xf0 + index)
|
|
}
|
|
authAPDU := buildUSIMAuthenticateAPDU(challenge)
|
|
authCommand := fmt.Sprintf(
|
|
`AT+CGLA=1,%d,"%s"`,
|
|
len(authAPDU)*2,
|
|
strings.ToUpper(hex.EncodeToString(authAPDU)),
|
|
)
|
|
chainedResponse := logicalChainedUSIMResponse()
|
|
if len(chainedResponse)-2 != 0x35 {
|
|
t.Fatalf(
|
|
"test response body length = %d, want 0x35",
|
|
len(chainedResponse)-2,
|
|
)
|
|
}
|
|
encodedResponse := strings.ToUpper(hex.EncodeToString(chainedResponse))
|
|
|
|
transcript := &ec20Transcript{
|
|
t: t,
|
|
steps: append(
|
|
identityTranscriptSteps("234150123456789"),
|
|
ec20TranscriptStep{
|
|
command: "AT+CCID",
|
|
lines: []string{"+CCID: 8944101234567890123"},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: "AT+CUAD",
|
|
lines: []string{
|
|
`+CUAD: 22,"61094F07A0000000871002"`,
|
|
},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: `AT+CCHO="A0000000871002"`,
|
|
lines: []string{"+CCHO: 1"},
|
|
},
|
|
ec20TranscriptStep{command: "AT+CCHC=1"},
|
|
ec20TranscriptStep{
|
|
command: "AT+CCID",
|
|
lines: []string{"+CCID: 8944101234567890123"},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: `AT+CCHO="A0000000871002"`,
|
|
lines: []string{"+CCHO: 1"},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: authCommand,
|
|
sensitive: true,
|
|
lines: []string{`+CGLA: 4,"6135"`},
|
|
},
|
|
ec20TranscriptStep{
|
|
command: `AT+CGLA=1,10,"00C0000035"`,
|
|
sensitive: true,
|
|
lines: []string{fmt.Sprintf(
|
|
`+CGLA: %d,"%s"`,
|
|
len(encodedResponse),
|
|
encodedResponse,
|
|
)},
|
|
},
|
|
ec20TranscriptStep{command: "AT+CCHC=1"},
|
|
),
|
|
}
|
|
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
|
if err != nil {
|
|
t.Fatalf("ReadIdentity: %v", err)
|
|
}
|
|
if _, err := adapter.CheckReady(context.Background(), identity); err != nil {
|
|
t.Fatalf("CheckReady: %v", err)
|
|
}
|
|
result, err := adapter.Authenticate(
|
|
context.Background(),
|
|
identity,
|
|
challenge,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Authenticate: %v", err)
|
|
}
|
|
if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) ||
|
|
len(result.CK) != 16 ||
|
|
len(result.IK) != 16 {
|
|
t.Fatalf(
|
|
"AKA result RES=%x CK=%d IK=%d",
|
|
result.RES,
|
|
len(result.CK),
|
|
len(result.IK),
|
|
)
|
|
}
|
|
transcript.assertDone()
|
|
}
|
|
|
|
func TestEC20AdapterReadsExplicitHomePLMNAndKnownAssignmentFallback(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
steps []ec20TranscriptStep
|
|
}{
|
|
{
|
|
name: "EF_AD",
|
|
steps: identityTranscriptSteps("234150123456789"),
|
|
},
|
|
{
|
|
name: "assigned HPLMN when EF_AD omits MNC length",
|
|
steps: append(
|
|
identityTranscriptStepsWithoutEFAD("234150123456789"),
|
|
ec20TranscriptStep{
|
|
command: "AT+CRSM=176,28589,0,0,4",
|
|
err: errors.New("not available"),
|
|
final: "ERROR",
|
|
},
|
|
ec20TranscriptStep{
|
|
command: "AT+CRSM=176,28589,0,0,0",
|
|
err: errors.New("not available"),
|
|
final: "ERROR",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
transcript := &ec20Transcript{t: t, steps: test.steps}
|
|
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
|
if err != nil {
|
|
t.Fatalf("ReadIdentity: %v", err)
|
|
}
|
|
if identity.HomeMCC != "234" || identity.HomeMNC != "15" {
|
|
t.Fatalf(
|
|
"home PLMN = %s/%s, want 234/15",
|
|
identity.HomeMCC,
|
|
identity.HomeMNC,
|
|
)
|
|
}
|
|
transcript.assertDone()
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
|
|
tests := map[string]string{
|
|
"204040123456789": "204/04",
|
|
"234150123456789": "234/15",
|
|
"234870123456789": "234/87",
|
|
}
|
|
for imsi, want := range tests {
|
|
mcc, mnc, ok := assignedHomePLMN(imsi)
|
|
if got := mcc + "/" + mnc; !ok || got != want {
|
|
t.Errorf("assignedHomePLMN(%q) = %q, %v; want %q", imsi, got, ok, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
transcript := &ec20Transcript{
|
|
t: t,
|
|
steps: []ec20TranscriptStep{
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
|
{
|
|
command: "AT+CGACT?",
|
|
lines: []string{"+CGACT: 1,1", "+CGACT: 2,0"},
|
|
},
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
|
{command: "AT+CFUN=4"},
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
|
|
{
|
|
command: "AT+CGACT?",
|
|
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
|
|
},
|
|
{
|
|
command: "AT+CGACT?",
|
|
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
|
|
},
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
|
|
{
|
|
command: "AT+CGACT?",
|
|
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
|
|
},
|
|
{
|
|
command: "AT+CGACT?",
|
|
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
|
|
},
|
|
},
|
|
}
|
|
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{
|
|
PureAirplanePolicy: func(string) bool { return true },
|
|
RestoreCellularData: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot, err := adapter.Snapshot(context.Background(), "ec20-1")
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
if !snapshot.CellularDataEnabled ||
|
|
snapshot.OperatingMode != 1 ||
|
|
!snapshot.PureAirplanePolicy {
|
|
t.Fatalf("snapshot = %#v", snapshot)
|
|
}
|
|
if err := adapter.EnterVoWiFiRFOff(
|
|
context.Background(),
|
|
"ec20-1",
|
|
); err != nil {
|
|
t.Fatalf("EnterVoWiFiRFOff: %v", err)
|
|
}
|
|
if err := adapter.StopCellularData(
|
|
context.Background(),
|
|
"ec20-1",
|
|
); err != nil {
|
|
t.Fatalf("StopCellularData: %v", err)
|
|
}
|
|
if err := adapter.Restore(
|
|
context.Background(),
|
|
"ec20-1",
|
|
snapshot,
|
|
); err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
transcript.assertDone()
|
|
}
|
|
|
|
func TestEC20AdapterNeverStartsCellularDataByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
transcript := &ec20Transcript{
|
|
t: t,
|
|
steps: []ec20TranscriptStep{
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
|
{command: "AT+CGACT?", lines: []string{"+CGACT: 1,1"}},
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
|
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
|
{command: "AT+CGACT?", lines: []string{"+CGACT: 1,1"}},
|
|
{command: "AT+CGACT=0,1"},
|
|
{command: "AT+CGACT?", lines: []string{"+CGACT: 1,0"}},
|
|
},
|
|
}
|
|
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot, err := adapter.Snapshot(context.Background(), "ec20-1")
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
if err := adapter.Restore(context.Background(), "ec20-1", snapshot); err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
transcript.assertDone()
|
|
}
|
|
|
|
func identityTranscriptSteps(imsi string) []ec20TranscriptStep {
|
|
return append(
|
|
identityTranscriptStepsWithoutEFAD(imsi),
|
|
ec20TranscriptStep{
|
|
command: "AT+CRSM=176,28589,0,0,4",
|
|
lines: []string{`+CRSM: 144,0,"00000002"`},
|
|
},
|
|
)
|
|
}
|
|
|
|
func identityTranscriptStepsWithoutEFAD(imsi string) []ec20TranscriptStep {
|
|
return []ec20TranscriptStep{
|
|
{command: "AT+CPIN?", lines: []string{"+CPIN: READY"}},
|
|
{command: "AT+CIMI", lines: []string{imsi}},
|
|
{
|
|
command: "AT+CCID",
|
|
lines: []string{"+CCID: 8944101234567890123"},
|
|
},
|
|
{command: "AT+CGSN", lines: []string{"867530912345678"}},
|
|
}
|
|
}
|
|
|
|
func successfulUSIMResponse() []byte {
|
|
res := []byte{1, 2, 3, 4, 5, 6, 7, 8}
|
|
ck := bytes.Repeat([]byte{0x11}, 16)
|
|
ik := bytes.Repeat([]byte{0x22}, 16)
|
|
kc := bytes.Repeat([]byte{0x33}, 8)
|
|
value := []byte{byte(len(res))}
|
|
value = append(value, res...)
|
|
value = append(value, byte(len(ck)))
|
|
value = append(value, ck...)
|
|
value = append(value, byte(len(ik)))
|
|
value = append(value, ik...)
|
|
value = append(value, byte(len(kc)))
|
|
value = append(value, kc...)
|
|
raw := []byte{0xdb}
|
|
raw = append(raw, value...)
|
|
return append(raw, 0x90, 0x00)
|
|
}
|
|
|
|
func logicalChainedUSIMResponse() []byte {
|
|
res := []byte{1, 2, 3, 4, 5, 6, 7, 8}
|
|
ck := bytes.Repeat([]byte{0x11}, 16)
|
|
ik := bytes.Repeat([]byte{0x22}, 16)
|
|
kc := bytes.Repeat([]byte{0x33}, 8)
|
|
value := []byte{byte(len(res))}
|
|
value = append(value, res...)
|
|
value = append(value, byte(len(ck)))
|
|
value = append(value, ck...)
|
|
value = append(value, byte(len(ik)))
|
|
value = append(value, ik...)
|
|
value = append(value, byte(len(kc)))
|
|
value = append(value, kc...)
|
|
raw := []byte{0xdb}
|
|
raw = append(raw, value...)
|
|
return append(raw, 0x90, 0x00)
|
|
}
|
|
|
|
func synchronizationFailureUSIMResponse() []byte {
|
|
auts := make([]byte, 14)
|
|
for index := range auts {
|
|
auts[index] = byte(0xa0 + index)
|
|
}
|
|
raw := []byte{0xdc, byte(len(auts))}
|
|
raw = append(raw, auts...)
|
|
return append(raw, 0x90, 0x00)
|
|
}
|