device_features_api_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "vocat/internal/device"
  10. "vocat/internal/store"
  11. )
  12. func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
  13. t.Helper()
  14. var envelope struct {
  15. Data map[string]any `json:"data"`
  16. }
  17. if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
  18. t.Fatalf("decode response: %v (body=%s)", err, recorder.Body.String())
  19. }
  20. return envelope.Data
  21. }
  22. func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) {
  23. groups := []map[string]any{{"eid": "", "aidHex": "", "profiles": []any{}}}
  24. chipInfo := map[string]any{
  25. "eids": []any{map[string]any{
  26. "eid": "89086030202200000025000015085962",
  27. "aid": "A0000005591010FFFFFFFF8900000100",
  28. }},
  29. }
  30. attachSingleEUICCIdentity(groups, chipInfo)
  31. if groups[0]["eid"] != "89086030202200000025000015085962" {
  32. t.Fatalf("group EID = %v", groups[0]["eid"])
  33. }
  34. if groups[0]["aidHex"] != "A0000005591010FFFFFFFF8900000100" {
  35. t.Fatalf("group AID = %v", groups[0]["aidHex"])
  36. }
  37. }
  38. func TestHandleOperatorScanReturnsOperators(t *testing.T) {
  39. server := &Server{
  40. logger: regionTestLogger(),
  41. devices: fakeDeviceController{scanResult: device.OperatorScanResult{
  42. Status: "complete",
  43. Operators: []device.ScannedOperator{
  44. {Status: "current", Name: "China Mobile", Numeric: "46000", Act: "LTE"},
  45. {Status: "available", Name: "China Unicom", Numeric: "46001", Act: "LTE"},
  46. },
  47. }},
  48. }
  49. recorder := httptest.NewRecorder()
  50. server.handleOperatorScan(recorder, httptest.NewRequest(http.MethodGet, "/scan", nil), "dev1")
  51. if recorder.Code != http.StatusOK {
  52. t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
  53. }
  54. data := decodeData(t, recorder)
  55. if data["status"] != "complete" {
  56. t.Fatalf("scan status = %v", data["status"])
  57. }
  58. candidates, ok := data["candidates"].([]any)
  59. if !ok || len(candidates) != 2 {
  60. t.Fatalf("candidates = %v", data["candidates"])
  61. }
  62. first := candidates[0].(map[string]any)
  63. if first["plmn"] != "46000" || first["status"] != "current" || first["operatorName"] != "China Mobile" {
  64. t.Fatalf("first candidate = %v", first)
  65. }
  66. }
  67. func TestHandleOperatorScanStreamEmitsTerminalEvent(t *testing.T) {
  68. server := &Server{
  69. logger: regionTestLogger(),
  70. devices: fakeDeviceController{scanResult: device.OperatorScanResult{
  71. Status: "complete",
  72. Operators: []device.ScannedOperator{{Status: "current", Name: "CMCC", Numeric: "46000"}},
  73. }},
  74. }
  75. recorder := httptest.NewRecorder()
  76. server.handleOperatorScanStream(recorder, httptest.NewRequest(http.MethodGet, "/scan/stream", nil), "dev1")
  77. body := recorder.Body.String()
  78. if !strings.Contains(body, "event: operator_scan") {
  79. t.Fatalf("expected operator_scan events, got %q", body)
  80. }
  81. if !strings.Contains(body, `"status":"running"`) || !strings.Contains(body, `"status":"complete"`) {
  82. t.Fatalf("expected running then complete, got %q", body)
  83. }
  84. }
  85. func TestHandleUSSDContinueAndCancel(t *testing.T) {
  86. server := &Server{
  87. logger: regionTestLogger(),
  88. maxRequestBodyBytes: 4096,
  89. devices: fakeDeviceController{ussdResult: device.USSDResult{
  90. Status: "awaiting_input", Text: "Main menu", SessionID: "abc123", Continueable: true,
  91. }},
  92. }
  93. request := httptest.NewRequest(http.MethodPost, "/continue", strings.NewReader(`{"session_id":"abc123","input":"1"}`))
  94. request.Header.Set("Content-Type", "application/json")
  95. recorder := httptest.NewRecorder()
  96. server.handleUSSDContinue(recorder, request)
  97. if recorder.Code != http.StatusOK {
  98. t.Fatalf("continue status = %d, body=%s", recorder.Code, recorder.Body.String())
  99. }
  100. data := decodeData(t, recorder)
  101. result, _ := data["result"].(map[string]any)
  102. if result["status"] != "awaiting_input" || data["session_id"] != "abc123" {
  103. t.Fatalf("continue data = %v", data)
  104. }
  105. cancelReq := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(`{"session_id":"abc123"}`))
  106. cancelReq.Header.Set("Content-Type", "application/json")
  107. cancelRec := httptest.NewRecorder()
  108. server.handleUSSDCancel(cancelRec, cancelReq)
  109. if cancelRec.Code != http.StatusOK {
  110. t.Fatalf("cancel status = %d, body=%s", cancelRec.Code, cancelRec.Body.String())
  111. }
  112. }
  113. func TestHandleUSSDContinueRequiresSession(t *testing.T) {
  114. server := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}}
  115. request := httptest.NewRequest(http.MethodPost, "/continue", strings.NewReader(`{"input":"1"}`))
  116. request.Header.Set("Content-Type", "application/json")
  117. recorder := httptest.NewRecorder()
  118. server.handleUSSDContinue(recorder, request)
  119. if recorder.Code != http.StatusBadRequest {
  120. t.Fatalf("missing session status = %d, want 400", recorder.Code)
  121. }
  122. }
  123. func TestHandleCardPoliciesListsAll(t *testing.T) {
  124. database, err := store.Open(context.Background(), ":memory:")
  125. if err != nil {
  126. t.Fatal(err)
  127. }
  128. t.Cleanup(func() { _ = database.Close() })
  129. if err := database.UpsertCardPolicy(context.Background(), store.CardPolicy{
  130. ICCID: "89860001", NetworkEnabled: true, IPVersion: "IPV4V6", Source: "manual",
  131. }); err != nil {
  132. t.Fatal(err)
  133. }
  134. server := &Server{store: database, logger: regionTestLogger()}
  135. recorder := httptest.NewRecorder()
  136. server.handleCardPolicies(recorder, httptest.NewRequest(http.MethodGet, "/api/cards/policies", nil))
  137. if recorder.Code != http.StatusOK {
  138. t.Fatalf("status = %d", recorder.Code)
  139. }
  140. var envelope struct {
  141. Data []map[string]any `json:"data"`
  142. }
  143. if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
  144. t.Fatal(err)
  145. }
  146. if len(envelope.Data) != 1 || envelope.Data[0]["iccid"] != "89860001" {
  147. t.Fatalf("policies = %v", envelope.Data)
  148. }
  149. }
  150. func TestHandleESIMShapes(t *testing.T) {
  151. server := &Server{logger: regionTestLogger()}
  152. overview := httptest.NewRecorder()
  153. server.handleESIM(overview, httptest.NewRequest(http.MethodGet, "/esim", nil), []string{}, "dev1", false)
  154. if overview.Code != http.StatusOK {
  155. t.Fatalf("overview status = %d", overview.Code)
  156. }
  157. if data := decodeData(t, overview); data["chipInfo"] != nil {
  158. t.Fatalf("overview chipInfo = %v, want nil (empty state)", data["chipInfo"])
  159. }
  160. profiles := httptest.NewRecorder()
  161. server.handleESIM(profiles, httptest.NewRequest(http.MethodGet, "/esim/profiles", nil), []string{"profiles"}, "dev1", false)
  162. if profiles.Code != http.StatusOK {
  163. t.Fatalf("profiles status = %d", profiles.Code)
  164. }
  165. notif := httptest.NewRecorder()
  166. server.handleESIM(notif, httptest.NewRequest(http.MethodGet, "/esim/notifications", nil), []string{"notifications"}, "dev1", false)
  167. if notif.Code != http.StatusOK {
  168. t.Fatalf("notifications status = %d", notif.Code)
  169. }
  170. // Download is a GET+SSE endpoint, so POST is rejected.
  171. downloadPost := httptest.NewRecorder()
  172. server.handleESIM(downloadPost, httptest.NewRequest(http.MethodPost, "/esim/actions/download", nil), []string{"actions", "download"}, "dev1", false)
  173. if downloadPost.Code != http.StatusMethodNotAllowed {
  174. t.Fatalf("download POST status = %d, want 405", downloadPost.Code)
  175. }
  176. // Download with no device manager reports 503.
  177. download := httptest.NewRecorder()
  178. server.handleESIM(download, httptest.NewRequest(http.MethodGet, "/esim/actions/download?smdp=rsp.example.com", nil), []string{"actions", "download"}, "dev1", false)
  179. if download.Code != http.StatusServiceUnavailable {
  180. t.Fatalf("download (no device) status = %d, want 503", download.Code)
  181. }
  182. // Switch with no physical modem present reports 503.
  183. absent := httptest.NewRecorder()
  184. server.handleESIM(absent, httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001"}`)), []string{"actions", "switch"}, "dev1", false)
  185. if absent.Code != http.StatusServiceUnavailable {
  186. t.Fatalf("switch (no device) status = %d, want 503", absent.Code)
  187. }
  188. // Switch happy path: a present device + fake controller switches by ICCID.
  189. present := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}}
  190. swOK := httptest.NewRecorder()
  191. swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0"}`))
  192. swReq.Header.Set("Content-Type", "application/json")
  193. present.handleESIM(swOK, swReq, []string{"actions", "switch"}, "dev1", true)
  194. if swOK.Code != http.StatusOK {
  195. t.Fatalf("switch happy-path status = %d, body=%s", swOK.Code, swOK.Body.String())
  196. }
  197. if data := decodeData(t, swOK); data["status"] != "switched" || data["verified"] != true {
  198. t.Fatalf("switch data = %v", data)
  199. }
  200. // Disable happy path routes the active profile to ES10c DisableProfile.
  201. disableOK := httptest.NewRecorder()
  202. disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
  203. disableReq.Header.Set("Content-Type", "application/json")
  204. present.handleESIM(disableOK, disableReq, []string{"actions", "disable"}, "dev1", true)
  205. if disableOK.Code != http.StatusOK {
  206. t.Fatalf("disable happy-path status = %d, body=%s", disableOK.Code, disableOK.Body.String())
  207. }
  208. if data := decodeData(t, disableOK); data["status"] != "disabled" || data["recovering"] != true {
  209. t.Fatalf("disable data = %v", data)
  210. }
  211. // Rename happy path routes PATCH to ES10c SetNickname support.
  212. renameOK := httptest.NewRecorder()
  213. renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
  214. renameReq.Header.Set("Content-Type", "application/json")
  215. present.handleESIM(renameOK, renameReq, []string{"profiles", "8900000000000000001"}, "dev1", true)
  216. if renameOK.Code != http.StatusOK {
  217. t.Fatalf("rename happy-path status = %d, body=%s", renameOK.Code, renameOK.Body.String())
  218. }
  219. if data := decodeData(t, renameOK); data["status"] != "renamed" || data["name"] != "Test profile" {
  220. t.Fatalf("rename data = %v", data)
  221. }
  222. // Download on a present device but with no smdp address reports 400.
  223. dlNoSmdp := httptest.NewRecorder()
  224. present.handleESIM(dlNoSmdp, httptest.NewRequest(http.MethodGet, "/esim/actions/download", nil), []string{"actions", "download"}, "dev1", true)
  225. if dlNoSmdp.Code != http.StatusBadRequest {
  226. t.Fatalf("download (no smdp) status = %d, want 400", dlNoSmdp.Code)
  227. }
  228. }
  229. func TestHandleFixUSBNet(t *testing.T) {
  230. server := &Server{
  231. logger: regionTestLogger(),
  232. maxRequestBodyBytes: 4096,
  233. devices: fakeDeviceController{usbNetMode: device.USBNetMode{Mode: 0, Name: "QMI"}},
  234. }
  235. request := httptest.NewRequest(http.MethodPost, "/fix-usbnet", strings.NewReader(`{"at_port":"/dev/ttyUSB2","mode":0}`))
  236. request.Header.Set("Content-Type", "application/json")
  237. recorder := httptest.NewRecorder()
  238. server.handleFixUSBNet(recorder, request)
  239. if recorder.Code != http.StatusOK {
  240. t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
  241. }
  242. if data := decodeData(t, recorder); data["mode"] != float64(0) || data["name"] != "QMI" {
  243. t.Fatalf("fix-usbnet data = %v", data)
  244. }
  245. }
  246. func TestHandleUpdateApplyIsSafeNoop(t *testing.T) {
  247. server := &Server{logger: regionTestLogger()}
  248. recorder := httptest.NewRecorder()
  249. server.handleUpdateApply(recorder, httptest.NewRequest(http.MethodPost, "/apply", nil))
  250. if recorder.Code != http.StatusOK {
  251. t.Fatalf("status = %d", recorder.Code)
  252. }
  253. if data := decodeData(t, recorder); data["applied"] != false {
  254. t.Fatalf("update apply must be a no-op, got %v", data)
  255. }
  256. }
  257. func TestE911WebsheetFlow(t *testing.T) {
  258. database, err := store.Open(context.Background(), ":memory:")
  259. if err != nil {
  260. t.Fatal(err)
  261. }
  262. t.Cleanup(func() { _ = database.Close() })
  263. server := &Server{
  264. store: database,
  265. logger: regionTestLogger(),
  266. websheets: newWebsheetManager(),
  267. maxRequestBodyBytes: 4096,
  268. }
  269. // 1. Create the websheet.
  270. createRec := httptest.NewRecorder()
  271. server.handleE911Websheet(createRec, httptest.NewRequest(http.MethodPost, "/e911", nil), store.Device{ID: "dev1"})
  272. if createRec.Code != http.StatusOK {
  273. t.Fatalf("create status = %d, body=%s", createRec.Code, createRec.Body.String())
  274. }
  275. createData := decodeData(t, createRec)
  276. embedURL, _ := createData["embed_url"].(string)
  277. if embedURL == "" || !strings.HasPrefix(embedURL, "/websheets/") {
  278. t.Fatalf("embed_url = %v", createData["embed_url"])
  279. }
  280. // 2. The form is served for a valid token.
  281. formRec := httptest.NewRecorder()
  282. server.handleWebsheet(formRec, httptest.NewRequest(http.MethodGet, embedURL, nil))
  283. if formRec.Code != http.StatusOK || !strings.Contains(formRec.Body.String(), "E911") {
  284. t.Fatalf("form status = %d", formRec.Code)
  285. }
  286. // 3. The callback stores the address, and done completes the session.
  287. callbackURL := strings.Replace(embedURL, "?", "/callback?", 1)
  288. callbackReq := httptest.NewRequest(http.MethodPost, callbackURL, strings.NewReader(`{"street":"1 Main St","city":"Springfield","country":"US"}`))
  289. callbackReq.Header.Set("Content-Type", "application/json")
  290. callbackRec := httptest.NewRecorder()
  291. server.handleWebsheet(callbackRec, callbackReq)
  292. if callbackRec.Code != http.StatusOK {
  293. t.Fatalf("callback status = %d, body=%s", callbackRec.Code, callbackRec.Body.String())
  294. }
  295. stored, err := database.AppSetting(context.Background(), "e911_address:dev1")
  296. if err != nil || !strings.Contains(string(stored.Value), "Springfield") {
  297. t.Fatalf("e911 address not persisted: %v %v", stored, err)
  298. }
  299. doneURL := strings.Replace(embedURL, "?", "/done?", 1)
  300. doneRec := httptest.NewRecorder()
  301. server.handleWebsheet(doneRec, httptest.NewRequest(http.MethodPost, doneURL, nil))
  302. if doneRec.Code != http.StatusOK {
  303. t.Fatalf("done status = %d", doneRec.Code)
  304. }
  305. }
  306. func TestE911WebsheetRejectsBadToken(t *testing.T) {
  307. server := &Server{logger: regionTestLogger(), websheets: newWebsheetManager()}
  308. session := server.websheets.create("dev1")
  309. recorder := httptest.NewRecorder()
  310. server.handleWebsheet(recorder, httptest.NewRequest(http.MethodGet, "/websheets/"+session.id+"?token=wrong", nil))
  311. if recorder.Code != http.StatusForbidden {
  312. t.Fatalf("bad token status = %d, want 403", recorder.Code)
  313. }
  314. }