settings_api_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. package server
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "io"
  7. "log/slog"
  8. "net/http"
  9. "net/http/httptest"
  10. "net/netip"
  11. "strings"
  12. "sync/atomic"
  13. "testing"
  14. "time"
  15. "vocat/internal/store"
  16. )
  17. type settingsAPITest struct {
  18. server *Server
  19. database *store.Store
  20. }
  21. func newSettingsAPITest(t *testing.T) settingsAPITest {
  22. t.Helper()
  23. database, err := store.Open(context.Background(), ":memory:")
  24. if err != nil {
  25. t.Fatal(err)
  26. }
  27. t.Cleanup(func() {
  28. if err := database.Close(); err != nil {
  29. t.Errorf("close database: %v", err)
  30. }
  31. })
  32. return settingsAPITest{
  33. server: &Server{
  34. store: database,
  35. logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
  36. maxRequestBodyBytes: 1 << 20,
  37. },
  38. database: database,
  39. }
  40. }
  41. func (test settingsAPITest) request(
  42. t *testing.T,
  43. method string,
  44. target string,
  45. body string,
  46. ) *httptest.ResponseRecorder {
  47. t.Helper()
  48. request := httptest.NewRequest(method, target, strings.NewReader(body))
  49. if body != "" {
  50. request.Header.Set("Content-Type", "application/json")
  51. }
  52. recorder := httptest.NewRecorder()
  53. cleanPath := strings.Trim(strings.TrimPrefix(request.URL.Path, "/api"), "/")
  54. if !test.server.routeSettingsAPI(recorder, request, cleanPath) {
  55. writeError(recorder, http.StatusNotFound, "not_found", "API endpoint not found")
  56. }
  57. return recorder
  58. }
  59. func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
  60. t.Helper()
  61. var response map[string]any
  62. if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
  63. t.Fatalf("decode response %q: %v", recorder.Body.String(), err)
  64. }
  65. return response
  66. }
  67. func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
  68. test := newSettingsAPITest(t)
  69. recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
  70. if recorder.Code != http.StatusOK {
  71. t.Fatalf("GET status = %d, body = %s", recorder.Code, recorder.Body)
  72. }
  73. response := decodeSettingsResponse(t, recorder)
  74. data, ok := response["data"].(map[string]any)
  75. if !ok || len(data) != len(notificationChannels) {
  76. t.Fatalf("notification channels = %#v", response["data"])
  77. }
  78. for _, channel := range notificationChannels {
  79. config, ok := data[channel].(map[string]any)
  80. if !ok || config["enabled"] != false {
  81. t.Fatalf("missing disabled channel %q: %#v", channel, config)
  82. }
  83. }
  84. if err := test.database.UpsertNotificationSetting(
  85. context.Background(),
  86. store.NotificationSetting{
  87. Channel: "telegram",
  88. Enabled: true,
  89. Config: json.RawMessage(
  90. `{"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1"}`,
  91. ),
  92. },
  93. ); err != nil {
  94. t.Fatal(err)
  95. }
  96. recorder = test.request(
  97. t,
  98. http.MethodPut,
  99. "/api/settings/notifications",
  100. `{"telegram":{"enabled":true,"bot_token":"********","chat_id":"2"}}`,
  101. )
  102. if recorder.Code != http.StatusOK {
  103. t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
  104. }
  105. if bytes.Contains(recorder.Body.Bytes(), []byte("abcdefghijklmnopqrstuvwxyz")) {
  106. t.Fatalf("PUT response leaked secret: %s", recorder.Body)
  107. }
  108. response = decodeSettingsResponse(t, recorder)
  109. data = response["data"].(map[string]any)
  110. telegram := data["telegram"].(map[string]any)
  111. if telegram["bot_token"] != store.SecretMask || telegram["chat_id"] != "2" {
  112. t.Fatalf("redacted Telegram config = %#v", telegram)
  113. }
  114. stored, err := test.database.NotificationSetting(context.Background(), "telegram")
  115. if err != nil {
  116. t.Fatal(err)
  117. }
  118. var storedConfig map[string]any
  119. if err := json.Unmarshal(stored.Config, &storedConfig); err != nil {
  120. t.Fatal(err)
  121. }
  122. if storedConfig["bot_token"] != "123456:abcdefghijklmnopqrstuvwxyz" ||
  123. storedConfig["chat_id"] != "2" {
  124. t.Fatalf("stored Telegram config = %#v", storedConfig)
  125. }
  126. }
  127. func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
  128. test := newSettingsAPITest(t)
  129. cases := []struct {
  130. name string
  131. body string
  132. code string
  133. }{
  134. {
  135. name: "unknown channel",
  136. body: `{"pagerduty":{"enabled":true}}`,
  137. code: "invalid_notification_channel",
  138. },
  139. {
  140. name: "missing enabled",
  141. body: `{"telegram":{"chat_id":"1"}}`,
  142. code: "invalid_notification_config",
  143. },
  144. {
  145. name: "wrong field type",
  146. body: `{"webhook":{"enabled":true,"urls":"https://example.com"}}`,
  147. code: "invalid_notification_config",
  148. },
  149. {
  150. name: "invalid Telegram chat id",
  151. body: `{"telegram":{"enabled":true,"chat_id":"group-name"}}`,
  152. code: "invalid_notification_config",
  153. },
  154. {
  155. name: "invalid Telegram admin id",
  156. body: `{"telegram":{"enabled":true,"admin_id":"-1"}}`,
  157. code: "invalid_notification_config",
  158. },
  159. {
  160. name: "insecure Telegram base URL",
  161. body: `{"telegram":{"enabled":true,"base_url":"http://example.com"}}`,
  162. code: "invalid_notification_config",
  163. },
  164. {
  165. name: "unknown field",
  166. body: `{"email":{"enabled":false,"smtp_host":"mail.example.com","typo":1}}`,
  167. code: "invalid_notification_config",
  168. },
  169. {
  170. name: "header value with newline",
  171. body: `{"webhook":{"enabled":true,"headers":{"X-Api-Key":"a\nb"}}}`,
  172. code: "invalid_notification_config",
  173. },
  174. {
  175. name: "header name with colon",
  176. body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
  177. code: "invalid_notification_config",
  178. },
  179. {
  180. name: "null body",
  181. body: `null`,
  182. code: "invalid_request",
  183. },
  184. }
  185. for _, item := range cases {
  186. t.Run(item.name, func(t *testing.T) {
  187. recorder := test.request(
  188. t,
  189. http.MethodPut,
  190. "/api/settings/notifications",
  191. item.body,
  192. )
  193. if recorder.Code != http.StatusBadRequest {
  194. t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
  195. }
  196. response := decodeSettingsResponse(t, recorder)
  197. detail := response["error"].(map[string]any)
  198. if detail["code"] != item.code {
  199. t.Fatalf("error = %#v", detail)
  200. }
  201. })
  202. }
  203. }
  204. func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) {
  205. test := newSettingsAPITest(t)
  206. var webhookHits atomic.Int32
  207. local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  208. webhookHits.Add(1)
  209. w.WriteHeader(http.StatusNoContent)
  210. }))
  211. defer local.Close()
  212. recorder := test.request(
  213. t,
  214. http.MethodPost,
  215. "/api/settings/notifications/webhook/test",
  216. `{"urls":[`+strconvJSON(local.URL)+`]}`,
  217. )
  218. if recorder.Code != http.StatusBadRequest {
  219. t.Fatalf("webhook SSRF status = %d, body = %s", recorder.Code, recorder.Body)
  220. }
  221. if webhookHits.Load() != 0 {
  222. t.Fatalf("blocked webhook reached local service %d times", webhookHits.Load())
  223. }
  224. response := decodeSettingsResponse(t, recorder)
  225. if response["error"].(map[string]any)["code"] != "unsafe_destination" {
  226. t.Fatalf("webhook SSRF response = %#v", response)
  227. }
  228. recorder = test.request(
  229. t,
  230. http.MethodPost,
  231. "/api/settings/notifications/telegram/test",
  232. `{"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1","base_url":"https://169.254.169.254"}`,
  233. )
  234. if recorder.Code != http.StatusBadRequest {
  235. t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body)
  236. }
  237. recorder = test.request(
  238. t,
  239. http.MethodPost,
  240. "/api/settings/notifications/email/test",
  241. `{"smtp_host":"127.0.0.1","smtp_port":25,"from_address":"[email protected]","to_addresses":["[email protected]"]}`,
  242. )
  243. if recorder.Code != http.StatusBadRequest {
  244. t.Fatalf("SMTP SSRF status = %d, body = %s", recorder.Code, recorder.Body)
  245. }
  246. recorder = test.request(
  247. t,
  248. http.MethodPost,
  249. "/api/settings/notifications/bark/test",
  250. `{"urls":[`+strconvJSON(local.URL)+`]}`,
  251. )
  252. if recorder.Code != http.StatusBadRequest {
  253. t.Fatalf("bark SSRF status = %d, body = %s", recorder.Code, recorder.Body)
  254. }
  255. response = decodeSettingsResponse(t, recorder)
  256. if response["error"].(map[string]any)["code"] != "unsafe_destination" {
  257. t.Fatalf("bark SSRF response = %#v", response)
  258. }
  259. recorder = test.request(
  260. t,
  261. http.MethodPost,
  262. "/api/settings/notifications/bark/test",
  263. `{}`,
  264. )
  265. if recorder.Code != http.StatusBadRequest {
  266. t.Fatalf("bark empty status = %d", recorder.Code)
  267. }
  268. response = decodeSettingsResponse(t, recorder)
  269. if response["error"].(map[string]any)["code"] != "notification_not_configured" {
  270. t.Fatalf("bark empty response = %#v", response)
  271. }
  272. // pushplus is a supported channel but has no connectivity test.
  273. recorder = test.request(
  274. t,
  275. http.MethodPost,
  276. "/api/settings/notifications/pushplus/test",
  277. `{}`,
  278. )
  279. if recorder.Code != http.StatusNotImplemented {
  280. t.Fatalf("unsupported notification status = %d", recorder.Code)
  281. }
  282. response = decodeSettingsResponse(t, recorder)
  283. if response["error"].(map[string]any)["code"] != "notification_test_unsupported" {
  284. t.Fatalf("unsupported response = %#v", response)
  285. }
  286. // Removed channels (feishu, qq, weixin) are no longer recognised at all.
  287. for _, removed := range []string{"feishu", "qq", "weixin"} {
  288. recorder = test.request(
  289. t,
  290. http.MethodPost,
  291. "/api/settings/notifications/"+removed+"/test",
  292. `{}`,
  293. )
  294. if recorder.Code != http.StatusNotFound {
  295. t.Fatalf("removed channel %q status = %d", removed, recorder.Code)
  296. }
  297. }
  298. }
  299. func strconvJSON(value string) string {
  300. encoded, _ := json.Marshal(value)
  301. return string(encoded)
  302. }
  303. func TestNotificationWebhookHeadersRoundTrip(t *testing.T) {
  304. test := newSettingsAPITest(t)
  305. recorder := test.request(
  306. t,
  307. http.MethodPut,
  308. "/api/settings/notifications",
  309. `{"webhook":{"enabled":true,"urls":["https://example.com/hook"],`+
  310. `"timeout_ms":30000,"retry_max":2,"headers":{"X-Api-Key":"abc"}}}`,
  311. )
  312. if recorder.Code != http.StatusOK {
  313. t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
  314. }
  315. stored, err := test.database.NotificationSetting(context.Background(), "webhook")
  316. if err != nil {
  317. t.Fatal(err)
  318. }
  319. var config map[string]any
  320. if err := json.Unmarshal(stored.Config, &config); err != nil {
  321. t.Fatal(err)
  322. }
  323. headers, ok := config["headers"].(map[string]any)
  324. if !ok || headers["X-Api-Key"] != "abc" {
  325. t.Fatalf("stored webhook headers = %#v", config)
  326. }
  327. if config["timeout_ms"] != float64(30000) {
  328. t.Fatalf("stored webhook timeout = %#v", config["timeout_ms"])
  329. }
  330. }
  331. func TestNotificationEmailUseSslRoundTrip(t *testing.T) {
  332. test := newSettingsAPITest(t)
  333. recorder := test.request(
  334. t,
  335. http.MethodPut,
  336. "/api/settings/notifications",
  337. `{"email":{"enabled":true,"use_ssl":true,"smtp_host":"smtp.example.com","smtp_port":465,`+
  338. `"username":"[email protected]","password":"mail_secret","from_address":"[email protected]",`+
  339. `"to_addresses":["[email protected]"]}}`,
  340. )
  341. if recorder.Code != http.StatusOK {
  342. t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
  343. }
  344. stored, err := test.database.NotificationSetting(context.Background(), "email")
  345. if err != nil {
  346. t.Fatal(err)
  347. }
  348. var config map[string]any
  349. if err := json.Unmarshal(stored.Config, &config); err != nil {
  350. t.Fatal(err)
  351. }
  352. if config["use_ssl"] != true || config["smtp_port"] != float64(465) {
  353. t.Fatalf("stored email config = %#v", config)
  354. }
  355. recorder = test.request(
  356. t,
  357. http.MethodPut,
  358. "/api/settings/notifications",
  359. `{"email":{"enabled":true,"use_ssl":"yes"}}`,
  360. )
  361. if recorder.Code != http.StatusBadRequest {
  362. t.Fatalf("wrong-type use_ssl status = %d, body = %s", recorder.Code, recorder.Body)
  363. }
  364. }
  365. func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
  366. test := newSettingsAPITest(t)
  367. const iccid = "89860012345678901234"
  368. recorder := test.request(
  369. t,
  370. http.MethodGet,
  371. "/api/cards/"+iccid+"/policy",
  372. "",
  373. )
  374. if recorder.Code != http.StatusOK {
  375. t.Fatalf("default policy status = %d, body = %s", recorder.Code, recorder.Body)
  376. }
  377. response := decodeSettingsResponse(t, recorder)
  378. policy := response["data"].(map[string]any)
  379. if policy["iccid"] != iccid || policy["source"] != "default" ||
  380. policy["ip_version"] != "IPV4V6" {
  381. t.Fatalf("default policy = %#v", policy)
  382. }
  383. recorder = test.request(
  384. t,
  385. http.MethodPut,
  386. "/api/cards/"+iccid+"/policy",
  387. `{"vowifi_enabled":true,"airplane_enabled":true,"apn":"ims","ip_version":"IPV4V6"}`,
  388. )
  389. if recorder.Code != http.StatusBadRequest {
  390. t.Fatalf("conflicting policy status = %d, body = %s", recorder.Code, recorder.Body)
  391. }
  392. recorder = test.request(
  393. t,
  394. http.MethodPut,
  395. "/api/cards/"+iccid+"/policy",
  396. `{"vowifi_enabled":true,"airplane_enabled":false,"apn":"ims","ip_version":"ipv4v6"}`,
  397. )
  398. if recorder.Code != http.StatusOK {
  399. t.Fatalf("save policy status = %d, body = %s", recorder.Code, recorder.Body)
  400. }
  401. response = decodeSettingsResponse(t, recorder)
  402. policy = response["data"].(map[string]any)
  403. if policy["source"] != "manual" || policy["vowifi_enabled"] != true ||
  404. policy["ip_version"] != "IPV4V6" {
  405. t.Fatalf("saved policy = %#v", policy)
  406. }
  407. stored, err := test.database.CardPolicy(context.Background(), iccid)
  408. if err != nil || !stored.VoWiFiEnabled || stored.APN != "ims" {
  409. t.Fatalf("stored policy = %+v, %v", stored, err)
  410. }
  411. recorder = test.request(t, http.MethodGet, "/api/cards/not-an-iccid/policy", "")
  412. if recorder.Code != http.StatusBadRequest {
  413. t.Fatalf("invalid ICCID status = %d", recorder.Code)
  414. }
  415. }
  416. func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) {
  417. test := newSettingsAPITest(t)
  418. period := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute)
  419. for _, bucket := range []store.TrafficBucket{
  420. {
  421. DeviceID: "ec20-1", Bucket: "day", PeriodStart: period,
  422. RXBytes: 100, TXBytes: 20,
  423. },
  424. {
  425. DeviceID: "ec20-2", Bucket: "day", PeriodStart: period,
  426. RXBytes: 50, TXBytes: 30,
  427. },
  428. {
  429. DeviceID: "ec20-1", Bucket: "week", PeriodStart: period,
  430. RXBytes: 9999, TXBytes: 9999,
  431. },
  432. } {
  433. if err := test.database.UpsertTrafficBucket(context.Background(), bucket); err != nil {
  434. t.Fatal(err)
  435. }
  436. }
  437. recorder := test.request(
  438. t,
  439. http.MethodGet,
  440. "/api/traffic/analysis?range=day",
  441. "",
  442. )
  443. if recorder.Code != http.StatusOK {
  444. t.Fatalf("traffic status = %d, body = %s", recorder.Code, recorder.Body)
  445. }
  446. response := decodeSettingsResponse(t, recorder)
  447. data := response["data"].(map[string]any)
  448. buckets := data["buckets"].([]any)
  449. if len(buckets) != 1 {
  450. t.Fatalf("traffic buckets = %#v", buckets)
  451. }
  452. bucket := buckets[0].(map[string]any)
  453. if bucket["rx_bytes"] != float64(150) ||
  454. bucket["tx_bytes"] != float64(50) ||
  455. bucket["total_bytes"] != float64(200) {
  456. t.Fatalf("aggregated bucket = %#v", bucket)
  457. }
  458. recorder = test.request(
  459. t,
  460. http.MethodGet,
  461. "/api/traffic/analysis?range=year",
  462. "",
  463. )
  464. if recorder.Code != http.StatusBadRequest {
  465. t.Fatalf("invalid traffic range status = %d", recorder.Code)
  466. }
  467. }
  468. func TestNotificationDestinationAddressPolicy(t *testing.T) {
  469. blocked := []string{
  470. "0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
  471. "169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1",
  472. "::1", "fc00::1", "fe80::1", "2001:db8::1",
  473. }
  474. for _, text := range blocked {
  475. address := netip.MustParseAddr(text)
  476. if publicNotificationAddress(address) {
  477. t.Errorf("%s was incorrectly accepted as public", text)
  478. }
  479. }
  480. for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
  481. address := netip.MustParseAddr(text)
  482. if !publicNotificationAddress(address) {
  483. t.Errorf("%s was incorrectly blocked", text)
  484. }
  485. }
  486. if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
  487. t.Fatal("localhost was not blocked")
  488. }
  489. if _, err := resolvePublicAddresses(
  490. context.Background(),
  491. "169.254.169.254",
  492. ); err == nil {
  493. t.Fatal("metadata IP was not blocked")
  494. }
  495. }
  496. func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
  497. client, err := restrictedHTTPClient(context.Background(), time.Minute, "")
  498. if err != nil {
  499. t.Fatal(err)
  500. }
  501. if client.Timeout != 10*time.Second {
  502. t.Fatalf("client timeout = %v", client.Timeout)
  503. }
  504. request := httptest.NewRequest(http.MethodGet, "https://example.com/next", nil)
  505. if err := client.CheckRedirect(request, nil); err == nil {
  506. t.Fatal("notification client followed a redirect")
  507. }
  508. }
  509. func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
  510. test := newSettingsAPITest(t)
  511. request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil)
  512. if test.server.routeSettingsAPI(httptest.NewRecorder(), request, "not-settings") {
  513. t.Fatal("unknown path was claimed by settings router")
  514. }
  515. }