| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545 |
- package server
- import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "net/netip"
- "strings"
- "sync/atomic"
- "testing"
- "time"
- "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 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" {
- t.Fatalf("default policy = %#v", policy)
- }
- 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.StatusBadRequest {
- t.Fatalf("conflicting 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["ip_version"] != "IPV4V6" {
- t.Fatalf("saved policy = %#v", policy)
- }
- stored, err := test.database.CardPolicy(context.Background(), iccid)
- if err != nil || !stored.VoWiFiEnabled || stored.APN != "ims" {
- t.Fatalf("stored policy = %+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)
- 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 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")
- }
- }
|