Files
VoCat/internal/server/settings_api_test.go
T
MengMengCode 0d738d4ce4 feat: add custom phone number support to card policies
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema.
- Updated the API to handle custom phone number input, including validation and normalization.
- Modified the CardPolicyPanel component to allow users to set and save a custom phone number.
- Enhanced the settings API to include the custom phone number in responses and updates.
- Added tests to ensure the correct functionality of custom phone number handling.
- Removed hardcoded environment variable for VOCAT_ADDR in service files.
2026-08-11 21:58:28 +08:00

738 lines
24 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"vocat/internal/developer"
"vocat/internal/store"
)
type settingsAPITest struct {
server *Server
database *store.Store
}
func newSettingsAPITest(t *testing.T) settingsAPITest {
t.Helper()
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close database: %v", err)
}
})
return settingsAPITest{
server: &Server{
store: database,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
maxRequestBodyBytes: 1 << 20,
},
database: database,
}
}
func (test settingsAPITest) request(
t *testing.T,
method string,
target string,
body string,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, target, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
cleanPath := strings.Trim(strings.TrimPrefix(request.URL.Path, "/api"), "/")
if !test.server.routeSettingsAPI(recorder, request, cleanPath) {
writeError(recorder, http.StatusNotFound, "not_found", "API endpoint not found")
}
return recorder
}
func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
t.Helper()
var response map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response %q: %v", recorder.Body.String(), err)
}
return response
}
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
if recorder.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", recorder.Code, recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
data, ok := response["data"].(map[string]any)
if !ok || len(data) != len(notificationChannels) {
t.Fatalf("notification channels = %#v", response["data"])
}
for _, channel := range notificationChannels {
config, ok := data[channel].(map[string]any)
if !ok || config["enabled"] != false {
t.Fatalf("missing disabled channel %q: %#v", channel, config)
}
}
if err := test.database.UpsertNotificationSetting(
context.Background(),
store.NotificationSetting{
Channel: "telegram",
Enabled: true,
Config: json.RawMessage(
`{"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1"}`,
),
},
); err != nil {
t.Fatal(err)
}
recorder = test.request(
t,
http.MethodPut,
"/api/settings/notifications",
`{"telegram":{"enabled":true,"bot_token":"********","chat_id":"2"}}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
if bytes.Contains(recorder.Body.Bytes(), []byte("abcdefghijklmnopqrstuvwxyz")) {
t.Fatalf("PUT response leaked secret: %s", recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
data = response["data"].(map[string]any)
telegram := data["telegram"].(map[string]any)
if telegram["bot_token"] != store.SecretMask || telegram["chat_id"] != "2" {
t.Fatalf("redacted Telegram config = %#v", telegram)
}
stored, err := test.database.NotificationSetting(context.Background(), "telegram")
if err != nil {
t.Fatal(err)
}
var storedConfig map[string]any
if err := json.Unmarshal(stored.Config, &storedConfig); err != nil {
t.Fatal(err)
}
if storedConfig["bot_token"] != "123456:abcdefghijklmnopqrstuvwxyz" ||
storedConfig["chat_id"] != "2" {
t.Fatalf("stored Telegram config = %#v", storedConfig)
}
}
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
test := newSettingsAPITest(t)
cases := []struct {
name string
body string
code string
}{
{
name: "unknown channel",
body: `{"pagerduty":{"enabled":true}}`,
code: "invalid_notification_channel",
},
{
name: "missing enabled",
body: `{"telegram":{"chat_id":"1"}}`,
code: "invalid_notification_config",
},
{
name: "wrong field type",
body: `{"webhook":{"enabled":true,"urls":"https://example.com"}}`,
code: "invalid_notification_config",
},
{
name: "invalid Telegram chat id",
body: `{"telegram":{"enabled":true,"chat_id":"group-name"}}`,
code: "invalid_notification_config",
},
{
name: "invalid Telegram admin id",
body: `{"telegram":{"enabled":true,"admin_id":"-1"}}`,
code: "invalid_notification_config",
},
{
name: "insecure Telegram base URL",
body: `{"telegram":{"enabled":true,"base_url":"http://example.com"}}`,
code: "invalid_notification_config",
},
{
name: "unknown field",
body: `{"email":{"enabled":false,"smtp_host":"mail.example.com","typo":1}}`,
code: "invalid_notification_config",
},
{
name: "header value with newline",
body: `{"webhook":{"enabled":true,"headers":{"X-Api-Key":"a\nb"}}}`,
code: "invalid_notification_config",
},
{
name: "header name with colon",
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
code: "invalid_notification_config",
},
{
name: "null body",
body: `null`,
code: "invalid_request",
},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
recorder := test.request(
t,
http.MethodPut,
"/api/settings/notifications",
item.body,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
detail := response["error"].(map[string]any)
if detail["code"] != item.code {
t.Fatalf("error = %#v", detail)
}
})
}
}
func TestNotificationSettingsAcceptsTelegramReverseProxyTemplate(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(
t,
http.MethodPut,
"/api/settings/notifications",
`{"telegram":{"enabled":true,"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1","base_url":"https://telegram.example.com/bot%s/%s"}}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err := test.database.NotificationSetting(context.Background(), "telegram")
if err != nil {
t.Fatal(err)
}
var config map[string]any
if err := json.Unmarshal(stored.Config, &config); err != nil {
t.Fatal(err)
}
if config["base_url"] != "https://telegram.example.com/bot%s/%s" {
t.Fatalf("stored Telegram base URL = %#v", config["base_url"])
}
}
func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) {
test := newSettingsAPITest(t)
var webhookHits atomic.Int32
local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
webhookHits.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
defer local.Close()
recorder := test.request(
t,
http.MethodPost,
"/api/settings/notifications/webhook/test",
`{"urls":[`+strconvJSON(local.URL)+`]}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("webhook SSRF status = %d, body = %s", recorder.Code, recorder.Body)
}
if webhookHits.Load() != 0 {
t.Fatalf("blocked webhook reached local service %d times", webhookHits.Load())
}
response := decodeSettingsResponse(t, recorder)
if response["error"].(map[string]any)["code"] != "unsafe_destination" {
t.Fatalf("webhook SSRF response = %#v", response)
}
recorder = test.request(
t,
http.MethodPost,
"/api/settings/notifications/telegram/test",
`{"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1","base_url":"https://169.254.169.254"}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
t,
http.MethodPost,
"/api/settings/notifications/email/test",
`{"smtp_host":"127.0.0.1","smtp_port":25,"from_address":"[email protected]","to_addresses":["[email protected]"]}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("SMTP SSRF status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
t,
http.MethodPost,
"/api/settings/notifications/bark/test",
`{"urls":[`+strconvJSON(local.URL)+`]}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("bark SSRF status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
if response["error"].(map[string]any)["code"] != "unsafe_destination" {
t.Fatalf("bark SSRF response = %#v", response)
}
recorder = test.request(
t,
http.MethodPost,
"/api/settings/notifications/bark/test",
`{}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("bark empty status = %d", recorder.Code)
}
response = decodeSettingsResponse(t, recorder)
if response["error"].(map[string]any)["code"] != "notification_not_configured" {
t.Fatalf("bark empty response = %#v", response)
}
// pushplus is a supported channel but has no connectivity test.
recorder = test.request(
t,
http.MethodPost,
"/api/settings/notifications/pushplus/test",
`{}`,
)
if recorder.Code != http.StatusNotImplemented {
t.Fatalf("unsupported notification status = %d", recorder.Code)
}
response = decodeSettingsResponse(t, recorder)
if response["error"].(map[string]any)["code"] != "notification_test_unsupported" {
t.Fatalf("unsupported response = %#v", response)
}
// Removed channels (feishu, qq, weixin) are no longer recognised at all.
for _, removed := range []string{"feishu", "qq", "weixin"} {
recorder = test.request(
t,
http.MethodPost,
"/api/settings/notifications/"+removed+"/test",
`{}`,
)
if recorder.Code != http.StatusNotFound {
t.Fatalf("removed channel %q status = %d", removed, recorder.Code)
}
}
}
func strconvJSON(value string) string {
encoded, _ := json.Marshal(value)
return string(encoded)
}
func TestNotificationWebhookHeadersRoundTrip(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(
t,
http.MethodPut,
"/api/settings/notifications",
`{"webhook":{"enabled":true,"urls":["https://example.com/hook"],`+
`"timeout_ms":30000,"retry_max":2,"headers":{"X-Api-Key":"abc"}}}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err := test.database.NotificationSetting(context.Background(), "webhook")
if err != nil {
t.Fatal(err)
}
var config map[string]any
if err := json.Unmarshal(stored.Config, &config); err != nil {
t.Fatal(err)
}
headers, ok := config["headers"].(map[string]any)
if !ok || headers["X-Api-Key"] != "abc" {
t.Fatalf("stored webhook headers = %#v", config)
}
if config["timeout_ms"] != float64(30000) {
t.Fatalf("stored webhook timeout = %#v", config["timeout_ms"])
}
}
func TestNotificationEmailUseSslRoundTrip(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(
t,
http.MethodPut,
"/api/settings/notifications",
`{"email":{"enabled":true,"use_ssl":true,"smtp_host":"smtp.example.com","smtp_port":465,`+
`"username":"[email protected]","password":"mail_secret","from_address":"[email protected]",`+
`"to_addresses":["[email protected]"]}}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err := test.database.NotificationSetting(context.Background(), "email")
if err != nil {
t.Fatal(err)
}
var config map[string]any
if err := json.Unmarshal(stored.Config, &config); err != nil {
t.Fatal(err)
}
if config["use_ssl"] != true || config["smtp_port"] != float64(465) {
t.Fatalf("stored email config = %#v", config)
}
recorder = test.request(
t,
http.MethodPut,
"/api/settings/notifications",
`{"email":{"enabled":true,"use_ssl":"yes"}}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("wrong-type use_ssl status = %d, body = %s", recorder.Code, recorder.Body)
}
}
func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
test := newSettingsAPITest(t)
const iccid = "89860012345678901234"
recorder := test.request(
t,
http.MethodGet,
"/api/cards/"+iccid+"/policy",
"",
)
if recorder.Code != http.StatusOK {
t.Fatalf("default policy status = %d, body = %s", recorder.Code, recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" {
t.Fatalf("default policy = %#v", policy)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"custom_phone_number":"+86 (138) 0013-8000"}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any)
if policy["custom_phone_number"] != "+8613800138000" {
t.Fatalf("normalized custom phone number = %#v", policy)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"custom_phone_number":"+86-CALL-ME"}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid custom phone status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"vowifi_enabled":true,"airplane_enabled":true,"apn":"ims","ip_version":"IPV4V6"}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("RF-safe policy status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"vowifi_enabled":true,"airplane_enabled":false,"apn":"ims","ip_version":"ipv4v6"}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("save policy status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any)
if policy["source"] != "manual" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true || policy["ip_version"] != "IPV4V6" {
t.Fatalf("saved policy = %#v", policy)
}
stored, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("stored policy = %+v, %v", stored, err)
}
// Updating only the switches must preserve the ICCID-specific APN.
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"vowifi_enabled":false,"airplane_enabled":false}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("partially updated policy = %+v, %v", stored, err)
}
// Clearing the override restores system-number display without affecting the
// rest of this ICCID's policy.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"custom_phone_number":""}`)
if recorder.Code != http.StatusOK {
t.Fatalf("clear custom phone status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.CustomPhoneNumber != "" || stored.APN != "ims" {
t.Fatalf("cleared custom phone policy = %+v, %v", stored, err)
}
// APN-only updates are accepted without changing either switch.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("APN-only policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "mobile.example" || stored.IPVersion != "IP" {
t.Fatalf("APN-only updated policy = %+v, %v", stored, err)
}
// A profile can keep multiple custom APNs independently of the active APN.
recorder = test.request(t, http.MethodPost, "/api/cards/"+iccid+"/apns", `{
"apn":"custom.table","username":"gg","password":"p","proxy":"",
"mcc":"234","mnc":"10","ip_version":"IPV4V6",
"roaming_ip_version":"IP","auth_type":"PAP"
}`)
if recorder.Code != http.StatusCreated {
t.Fatalf("create custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
custom := response["data"].(map[string]any)
customID := int64(custom["id"].(float64))
recorder = test.request(t, http.MethodGet, "/api/cards/"+iccid+"/apns", "")
if recorder.Code != http.StatusOK {
t.Fatalf("list custom APNs status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
items := response["data"].(map[string]any)["items"].([]any)
if len(items) != 1 {
t.Fatalf("custom APNs = %#v", items)
}
listed := items[0].(map[string]any)
if listed["apn"] != "custom.table" || listed["username"] != "gg" ||
listed["has_password"] != true || listed["mcc"] != "234" || listed["mnc"] != "10" ||
listed["roaming_ip_version"] != "IP" || listed["auth_type"] != "PAP" {
t.Fatalf("custom APNs = %#v", items)
}
if _, exposed := listed["password"]; exposed {
t.Fatalf("custom APN API exposed stored password: %#v", listed)
}
storedAPN, err := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.table", "IPV4V6")
if err != nil || storedAPN.Username != "gg" || storedAPN.Password != "p" || storedAPN.AuthType != "PAP" {
t.Fatalf("stored custom APN = %#v, %v", storedAPN, err)
}
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
"apn":"custom.edited","username":"gg2","proxy":"","mcc":"234","mnc":"10",
"ip_version":"IPV4V6","roaming_ip_version":"IP","auth_type":"PAP"
}`)
if recorder.Code != http.StatusOK {
t.Fatalf("edit custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
storedAPN, err = test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.edited", "IPV4V6")
if err != nil || storedAPN.Username != "gg2" || storedAPN.Password != "p" {
t.Fatalf("editing custom APN did not preserve password: %#v, %v", storedAPN, err)
}
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"custom.edited","ip_version":"IPV4V6"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("activate custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
"apn":"custom.final","username":"gg2","clear_password":true,"proxy":"",
"mcc":"234","mnc":"10","ip_version":"IP","roaming_ip_version":"IPV4V6","auth_type":"CHAP"
}`)
if recorder.Code != http.StatusOK {
t.Fatalf("edit active custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
storedAPN, profileErr := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.final", "IP")
if err != nil || profileErr != nil || stored.APN != "custom.final" || stored.IPVersion != "IP" || storedAPN.Password != "" {
t.Fatalf("active APN edit was not synchronized: policy=%#v profile=%#v errors=%v/%v", stored, storedAPN, err, profileErr)
}
recorder = test.request(t, http.MethodDelete, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), "")
if recorder.Code != http.StatusOK {
t.Fatalf("delete custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.APN != "" || stored.IPVersion != "IPV4V6" {
t.Fatalf("deleting active custom APN did not restore automatic mode: %+v, %v", stored, err)
}
recorder = test.request(t, http.MethodGet, "/api/cards/not-an-iccid/policy", "")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid ICCID status = %d", recorder.Code)
}
}
func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) {
test := newSettingsAPITest(t)
test.server.developerEnabled = true
if err := test.database.UpsertAppSetting(context.Background(), store.AppSetting{
Key: developer.EnabledSettingKey, Value: json.RawMessage(`{"enabled":true}`),
}); err != nil {
t.Fatal(err)
}
period := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute)
for _, bucket := range []store.TrafficBucket{
{
DeviceID: "ec20-1", Bucket: "day", PeriodStart: period,
RXBytes: 100, TXBytes: 20,
},
{
DeviceID: "ec20-2", Bucket: "day", PeriodStart: period,
RXBytes: 50, TXBytes: 30,
},
{
DeviceID: "ec20-1", Bucket: "week", PeriodStart: period,
RXBytes: 9999, TXBytes: 9999,
},
} {
if err := test.database.UpsertTrafficBucket(context.Background(), bucket); err != nil {
t.Fatal(err)
}
}
recorder := test.request(
t,
http.MethodGet,
"/api/traffic/analysis?range=day",
"",
)
if recorder.Code != http.StatusOK {
t.Fatalf("traffic status = %d, body = %s", recorder.Code, recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
data := response["data"].(map[string]any)
buckets := data["buckets"].([]any)
if len(buckets) != 1 {
t.Fatalf("traffic buckets = %#v", buckets)
}
bucket := buckets[0].(map[string]any)
if bucket["rx_bytes"] != float64(150) ||
bucket["tx_bytes"] != float64(50) ||
bucket["total_bytes"] != float64(200) {
t.Fatalf("aggregated bucket = %#v", bucket)
}
recorder = test.request(
t,
http.MethodGet,
"/api/traffic/analysis?range=year",
"",
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid traffic range status = %d", recorder.Code)
}
}
func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodGet, "/api/traffic/analysis?range=week", "")
if recorder.Code != http.StatusForbidden {
t.Fatalf("traffic status = %d, want %d; body = %s", recorder.Code, http.StatusForbidden, recorder.Body)
}
}
func TestNotificationDestinationAddressPolicy(t *testing.T) {
blocked := []string{
"0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
"169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1",
"::1", "fc00::1", "fe80::1", "2001:db8::1",
}
for _, text := range blocked {
address := netip.MustParseAddr(text)
if publicNotificationAddress(address) {
t.Errorf("%s was incorrectly accepted as public", text)
}
}
for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
address := netip.MustParseAddr(text)
if !publicNotificationAddress(address) {
t.Errorf("%s was incorrectly blocked", text)
}
}
if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
t.Fatal("localhost was not blocked")
}
if _, err := resolvePublicAddresses(
context.Background(),
"169.254.169.254",
); err == nil {
t.Fatal("metadata IP was not blocked")
}
}
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
client, err := restrictedHTTPClient(context.Background(), time.Minute, "")
if err != nil {
t.Fatal(err)
}
if client.Timeout != 10*time.Second {
t.Fatalf("client timeout = %v", client.Timeout)
}
request := httptest.NewRequest(http.MethodGet, "https://example.com/next", nil)
if err := client.CheckRedirect(request, nil); err == nil {
t.Fatal("notification client followed a redirect")
}
}
func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
test := newSettingsAPITest(t)
request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil)
if test.server.routeSettingsAPI(httptest.NewRecorder(), request, "not-settings") {
t.Fatal("unknown path was claimed by settings router")
}
}
func TestParseMailAddressRejectsHeaderInjection(t *testing.T) {
for _, value := range []string{
"[email protected]\r\nBcc: [email protected]",
"[email protected]\nX-Test: injected",
"display\x00name <[email protected]>",
} {
if _, err := parseMailAddress(value); err == nil {
t.Errorf("parseMailAddress(%q) accepted header injection", value)
}
}
address, err := parseMailAddress("Vocat Alerts <[email protected]>")
if err != nil {
t.Fatal(err)
}
header := formatMailAddress(address)
if strings.ContainsAny(header, "\r\n") {
t.Fatalf("formatted address contains a line break: %q", header)
}
}