package server import ( "context" "errors" "net/netip" "strings" "testing" "time" "vocat/internal/device" "vocat/internal/modem" "vocat/internal/store" "vocat/internal/vowifi" ) func TestTelegramAPIURLSupportsBaseAndTemplate(t *testing.T) { tests := []struct { name string baseURL string want string }{ { name: "base URL", baseURL: "https://api.telegram.org", want: "https://api.telegram.org/bot123456:test-token/sendMessage", }, { name: "reverse proxy template", baseURL: "https://telegram.example.com/bot%s/%s", want: "https://telegram.example.com/bot123456:test-token/sendMessage", }, } for _, item := range tests { t.Run(item.name, func(t *testing.T) { got, err := telegramAPIURL(item.baseURL, "123456:test-token", "sendMessage") if err != nil { t.Fatal(err) } if got.String() != item.want { t.Fatalf("telegramAPIURL() = %q, want %q", got, item.want) } }) } } func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) { for _, value := range []string{ "https://telegram.example.com/bot%s/sendMessage", "https://%s.example.com/bot/token/%s", "http://telegram.example.com/bot%s/%s", } { if _, err := telegramAPIURL(value, "123456:test-token", "sendMessage"); err == nil { t.Errorf("telegramAPIURL(%q) unexpectedly succeeded", value) } else if strings.TrimSpace(err.Error()) == "" { t.Errorf("telegramAPIURL(%q) returned an empty error", value) } } } func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) { bot := &telegramBot{server: &Server{access: parsedAccessConfig{ cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")}, }}} ctx := bot.notificationDestinationContext(context.Background()) if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil { t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err) } if _, err := validateTelegramAPIURL(ctx, "https://169.254.169.254", "123456:test-token", "getUpdates"); err == nil { t.Fatal("metadata address became reachable through Telegram allowlist") } } func TestParseTelegramCommand(t *testing.T) { command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ") if command != "sms" || remainder != "EC20 +447700900123 hello world" { t.Fatalf("parseTelegramCommand() = %q, %q", command, remainder) } if command, _ := parseTelegramCommand("ordinary message"); command != "" { t.Fatalf("non-command parsed as %q", command) } } func TestSplitTelegramArgumentsPreservesMessageBody(t *testing.T) { parts := splitTelegramArguments(" EC20 +447700900123 code with spaces ", 3) if len(parts) != 3 || parts[0] != "EC20" || parts[1] != "+447700900123" || parts[2] != "code with spaces" { t.Fatalf("splitTelegramArguments() = %#v", parts) } } func TestValidTelegramDialNumber(t *testing.T) { for _, value := range []string{"10086", "+447700900123", "12345678901234567890"} { if !validTelegramDialNumber(value) { t.Errorf("validTelegramDialNumber(%q) = false", value) } } for _, value := range []string{"12", "+", "123;ATH", "12 34", "123456789012345678901"} { if validTelegramDialNumber(value) { t.Errorf("validTelegramDialNumber(%q) = true", value) } } } func TestResolveTelegramPhoneNumberPrefersCurrentSIMAssociation(t *testing.T) { snapshot := &device.Snapshot{ ICCID: "89441000400128013903", Phone: device.PhoneNumber{Number: "00000000000"}, } state := &vowifi.State{ ICCID: snapshot.ICCID, PhoneNumber: "+447386125520", } if got := resolveTelegramPhoneNumber("+447700900123", state, snapshot); got != "+447700900123" { t.Fatalf("resolved association number = %q", got) } if got := resolveTelegramPhoneNumber("", state, snapshot); got != "+447386125520" { t.Fatalf("resolved IMS number = %q", got) } } func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.T) { snapshot := &device.Snapshot{ ICCID: "current-card", Phone: device.PhoneNumber{Number: "00000000000"}, } state := &vowifi.State{ ICCID: "previous-card", PhoneNumber: "+447386083638", } if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" { t.Fatalf("stale or placeholder number leaked as %q", got) } for _, placeholder := range []string{"00000000000", "1111111111", "+0000000000", "not-a-number"} { if usableTelegramPhoneNumber(placeholder) { t.Errorf("placeholder %q was accepted", placeholder) } } } func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) { if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") { t.Fatalf("home carrier = %q", got) } if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") { t.Fatalf("profile brand overrode home carrier = %q", got) } if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" { t.Fatalf("unknown home carrier did not fall back to SPN: %q", got) } flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1} if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" { t.Fatalf("flight-mode serving network = %q", got) } serving := &device.Snapshot{OperatorCode: "46001", RegistrationStatus: 5, AccessTech: "LTE", Band: "B3"} if got := telegramCurrentNetwork(serving); !strings.Contains(got, "🇨🇳") || !strings.Contains(got, "已驻网(漫游)") { t.Fatalf("serving network = %q", got) } } func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) { bot := &telegramBot{pending: make(map[string]telegramPendingAction)} action := telegramPendingAction{Kind: "call", ChatID: -1001, AdminID: 42, CreatedAt: time.Now()} token, err := bot.putPending(action) if err != nil { t.Fatal(err) } if _, ok := bot.takePending(token, -1001, 41); ok { t.Fatal("different administrator consumed pending action") } if _, ok := bot.takePending(token, -1001, 42); ok { t.Fatal("an unauthorized attempt must invalidate the one-time action") } } func TestTelegramMenuCallbackParsing(t *testing.T) { prefix, token, operation, ok := parseTelegramMenuCallback("call:0123456789abcdef:answer") if !ok || prefix != "call" || token != "0123456789abcdef" || operation != "answer" { t.Fatalf("parsed callback = %q %q %q %t", prefix, token, operation, ok) } for _, invalid := range []string{"", "call:token", "unknown:token:op", "d::status"} { if _, _, _, ok := parseTelegramMenuCallback(invalid); ok { t.Fatalf("invalid callback %q was accepted", invalid) } } } func TestTelegramMenuPendingCanBeReusedButConfirmationCannot(t *testing.T) { bot := &telegramBot{pending: make(map[string]telegramPendingAction)} menuToken, err := bot.putPending(telegramPendingAction{ Kind: "menu_device", DeviceID: "EC20", ChatID: 1, AdminID: 2, CreatedAt: time.Now(), }) if err != nil { t.Fatal(err) } if _, ok := bot.getPending(menuToken, 1, 2); !ok { t.Fatal("first menu lookup failed") } if _, ok := bot.getPending(menuToken, 1, 2); !ok { t.Fatal("menu token was unexpectedly consumed") } confirmToken, err := bot.putPending(telegramPendingAction{ Kind: "sms", ChatID: 1, AdminID: 2, CreatedAt: time.Now(), }) if err != nil { t.Fatal(err) } if _, ok := bot.takePending(confirmToken, 1, 2); !ok { t.Fatal("confirmation token lookup failed") } if _, ok := bot.takePending(confirmToken, 1, 2); ok { t.Fatal("confirmation token was reusable") } } func TestTelegramInputStateIsScopedAndCancelable(t *testing.T) { bot := &telegramBot{inputs: make(map[string]telegramInputState)} bot.setInput(telegramInputState{Kind: "sms_phone", DeviceID: "EC20", ChatID: 10, AdminID: 20}) if state, ok := bot.input(10, 20); !ok || state.DeviceID != "EC20" || state.Kind != "sms_phone" { t.Fatalf("input state = %#v, %t", state, ok) } if _, ok := bot.input(10, 21); ok { t.Fatal("another administrator read the input state") } bot.clearInput(10, 20) if _, ok := bot.input(10, 20); ok { t.Fatal("cleared input state remained available") } } func TestFormatTelegramATIncludesFinalResult(t *testing.T) { if got := formatTelegramAT(modem.Response{Final: "OK"}); got != "OK" { t.Fatalf("formatTelegramAT(OK) = %q", got) } if got := formatTelegramAT(modem.Response{Lines: []string{"+CLCC: 1"}, Final: "OK"}); got != "+CLCC: 1\nOK" { t.Fatalf("formatTelegramAT(lines) = %q", got) } } func TestTelegramExecutesGuardedATForConfiguredDevice(t *testing.T) { database, err := store.Open(context.Background(), ":memory:") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) if err := database.UpsertDevice(context.Background(), store.Device{ID: "EC20", Name: "EC20"}); err != nil { t.Fatal(err) } bot := &telegramBot{server: &Server{ store: database, devices: fakeDeviceController{ entry: device.Device{ID: "EC20", Discovered: true}, atResponse: modem.Response{Lines: []string{"+CSQ: 18,99"}, Final: "OK"}, }, }} result, err := bot.executeATCommand(context.Background(), "EC20", "AT+CSQ") if err != nil { t.Fatal(err) } for _, expected := range []string{"设备:EC20", "> AT+CSQ", "+CSQ: 18,99", "OK"} { if !strings.Contains(result, expected) { t.Fatalf("AT result %q does not contain %q", result, expected) } } if _, err := bot.executeATCommand(context.Background(), "EC20", "AT+CFUN=0"); err == nil { t.Fatal("guarded AT command unexpectedly succeeded") } } func TestTelegramExecutesInteractiveUSSDForConfiguredDevice(t *testing.T) { database, err := store.Open(context.Background(), ":memory:") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) if err := database.UpsertDevice(context.Background(), store.Device{ID: "EC20", Name: "EC20"}); err != nil { t.Fatal(err) } bot := &telegramBot{server: &Server{ store: database, devices: fakeDeviceController{ entry: device.Device{ID: "EC20", Discovered: true}, ussdResult: device.USSDResult{ Code: "*100#", Text: "1. Balance\n2. Bundles", Status: "awaiting_input", SessionID: "0123456789abcdef", Continueable: true, }, }, }} result, err := bot.executeUSSDCommand(context.Background(), "EC20", "*100#") if err != nil { t.Fatal(err) } formatted := formatTelegramUSSD("EC20", result) for _, expected := range []string{ "设备:EC20", "状态:awaiting_input", "1. Balance", "请直接发送回复内容", } { if !strings.Contains(formatted, expected) { t.Fatalf("USSD result %q does not contain %q", formatted, expected) } } } func TestTelegramErrorsRedactBotTokens(t *testing.T) { token := "1234567890:abcdefghijklmnopqrstuvwxyzABCDE" err := errors.New(`Post "https://api.telegram.org/bot` + token + `/getUpdates": context canceled`) redacted := redactTelegramError(err, token) if strings.Contains(redacted.Error(), token) || !strings.Contains(redacted.Error(), "bot[REDACTED]") { t.Fatalf("redacted error = %q", redacted) } } func TestTelegramCallTransportFollowsConfiguredCardMode(t *testing.T) { controller := &telegramTestCallController{state: vowifi.State{IMSReady: true, Phase: vowifi.PhaseIMSReady}} bot := &telegramBot{server: &Server{vowifi: controller}} transport, gotController, err := bot.telegramCallTransport( store.Device{ID: "EC20", VoWiFiEnabled: true}, device.Device{Snapshot: &device.Snapshot{FlightMode: true}}, ) if err != nil || transport != "vowifi" || gotController == nil { t.Fatalf("VoWiFi route = %q, %#v, %v", transport, gotController, err) } transport, gotController, err = bot.telegramCallTransport( store.Device{ID: "EC20", VoWiFiEnabled: false}, device.Device{Snapshot: &device.Snapshot{FlightMode: false}}, ) if err != nil || transport != "cellular" || gotController != nil { t.Fatalf("cellular route = %q, %#v, %v", transport, gotController, err) } } func TestTelegramCallTransportDoesNotFallBackFromUnreadyVoWiFi(t *testing.T) { controller := &telegramTestCallController{state: vowifi.State{ Phase: vowifi.PhaseFailed, LastError: "SIP registration was rejected: SIP 403", }} bot := &telegramBot{server: &Server{vowifi: controller}} _, _, err := bot.telegramCallTransport( store.Device{ID: "EC20", VoWiFiEnabled: true}, device.Device{Snapshot: &device.Snapshot{FlightMode: true}}, ) if err == nil || !strings.Contains(err.Error(), "SIP 403") { t.Fatalf("unready VoWiFi route error = %v", err) } } func TestTelegramTimedVoWiFiCallUsesIMSAndHangsUpByCallID(t *testing.T) { controller := &telegramTestCallController{state: vowifi.State{IMSReady: true}} controller.dialResult = vowifi.Call{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "dialing"} controller.calls = []vowifi.Call{{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "active"}} bot := &telegramBot{server: &Server{vowifi: controller}} result, err := bot.executeTimedVoWiFiCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{ DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond, }, controller) if err != nil || !strings.Contains(result, "已接通") { t.Fatalf("timed VoWiFi result = %q, %v", result, err) } if controller.dialed != "+447700900123" || len(controller.hungUp) != 1 || controller.hungUp[0] != "ims-call-1" { t.Fatalf("IMS actions dial=%q hangup=%#v", controller.dialed, controller.hungUp) } } func TestTelegramTimedCellularCallUsesATDCLCCAndATH(t *testing.T) { commands := make([]string, 0, 3) devices := fakeDeviceController{atHandler: func(command string) (modem.Response, error) { commands = append(commands, command) switch { case strings.HasPrefix(command, "ATD"): return modem.Response{Final: "OK"}, nil case command == "AT+CLCC": return modem.Response{Lines: []string{`+CLCC: 1,0,2,0,0,"+447700900123",145`}, Final: "OK"}, nil case command == "ATH": return modem.Response{Final: "OK"}, nil default: return modem.Response{}, errors.New("unexpected command") } }} bot := &telegramBot{server: &Server{devices: devices}} result, err := bot.executeTimedCellularCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{ DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond, }, "physical") if err != nil || !strings.Contains(result, "正在拨号") { t.Fatalf("timed cellular result = %q, %v", result, err) } joined := strings.Join(commands, ",") for _, expected := range []string{"ATD+447700900123;", "AT+CLCC", "ATH"} { if !strings.Contains(joined, expected) { t.Fatalf("commands %q omit %q", joined, expected) } } } func TestTelegramVoWiFiFailureIncludesSIPDiagnostic(t *testing.T) { err := telegramVoWiFiCallFailure(vowifi.Call{State: "failed", SIPCode: 403, Reason: "Forbidden"}) if !strings.Contains(err.Error(), "SIP 403") || !strings.Contains(err.Error(), "Forbidden") { t.Fatalf("VoWiFi diagnostic = %q", err) } } func TestTelegramVoWiFi487IsReportedAsCancelledOutcome(t *testing.T) { result, err := telegramVoWiFiCallOutcome("888", vowifi.Call{ State: "failed", SIPCode: 487, Reason: "Request Terminated", }) if err != nil || !strings.Contains(result, "取消或终止") || !strings.Contains(result, "SIP 487") { t.Fatalf("487 outcome = %q, %v", result, err) } } type telegramTestCallController struct { state vowifi.State calls []vowifi.Call dialResult vowifi.Call dialErr error dialed string hungUp []string } func (controller *telegramTestCallController) State(string) (vowifi.State, error) { return controller.state, nil } func (controller *telegramTestCallController) RequestEnabled(string, bool) (vowifi.State, error) { return controller.state, nil } func (controller *telegramTestCallController) RequestReconnect(string) (vowifi.State, error) { return controller.state, nil } func (controller *telegramTestCallController) Calls(string) ([]vowifi.Call, error) { return append([]vowifi.Call(nil), controller.calls...), nil } func (controller *telegramTestCallController) DialCall(_ context.Context, _ string, number string) (vowifi.Call, error) { controller.dialed = number return controller.dialResult, controller.dialErr } func (controller *telegramTestCallController) AnswerCall(_ context.Context, _ string, id string) (vowifi.Call, error) { for _, call := range controller.calls { if call.ID == id { call.State = "active" return call, nil } } return vowifi.Call{}, errors.New("call not found") } func (controller *telegramTestCallController) HangupCall(_ context.Context, _ string, id string) error { controller.hungUp = append(controller.hungUp, id) return nil }