serve_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. package cmd
  2. import (
  3. "fmt"
  4. "math/rand"
  5. "os"
  6. "os/exec"
  7. "path/filepath"
  8. "testing"
  9. "time"
  10. "github.com/gorilla/websocket"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. "heckel.io/ntfy/v2/client"
  14. "heckel.io/ntfy/v2/test"
  15. "heckel.io/ntfy/v2/user"
  16. "heckel.io/ntfy/v2/util"
  17. )
  18. func TestParseUsers_Success(t *testing.T) {
  19. tests := []struct {
  20. name string
  21. input []string
  22. expected []*user.User
  23. }{
  24. {
  25. name: "single user",
  26. input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
  27. expected: []*user.User{
  28. {
  29. Name: "alice",
  30. Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
  31. Role: user.RoleUser,
  32. Provisioned: true,
  33. },
  34. },
  35. },
  36. {
  37. name: "multiple users with different roles",
  38. input: []string{
  39. "alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
  40. "bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
  41. },
  42. expected: []*user.User{
  43. {
  44. Name: "alice",
  45. Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
  46. Role: user.RoleUser,
  47. Provisioned: true,
  48. },
  49. {
  50. Name: "bob",
  51. Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
  52. Role: user.RoleAdmin,
  53. Provisioned: true,
  54. },
  55. },
  56. },
  57. {
  58. name: "empty input",
  59. input: []string{},
  60. expected: []*user.User{},
  61. },
  62. {
  63. name: "user with special characters in name",
  64. input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
  65. expected: []*user.User{
  66. {
  67. Name: "alice.test+123@example.com",
  68. Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
  69. Role: user.RoleUser,
  70. Provisioned: true,
  71. },
  72. },
  73. },
  74. }
  75. for _, tt := range tests {
  76. t.Run(tt.name, func(t *testing.T) {
  77. result, err := parseUsers(tt.input)
  78. require.NoError(t, err)
  79. require.Len(t, result, len(tt.expected))
  80. for i, expectedUser := range tt.expected {
  81. assert.Equal(t, expectedUser.Name, result[i].Name)
  82. assert.Equal(t, expectedUser.Hash, result[i].Hash)
  83. assert.Equal(t, expectedUser.Role, result[i].Role)
  84. assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned)
  85. }
  86. })
  87. }
  88. }
  89. func TestParseUsers_Errors(t *testing.T) {
  90. tests := []struct {
  91. name string
  92. input []string
  93. error string
  94. }{
  95. {
  96. name: "invalid format - too few parts",
  97. input: []string{"alice:hash"},
  98. error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'",
  99. },
  100. {
  101. name: "invalid format - too many parts",
  102. input: []string{"alice:hash:role:extra"},
  103. error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'",
  104. },
  105. {
  106. name: "invalid username",
  107. input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
  108. error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
  109. },
  110. {
  111. name: "invalid password hash - wrong prefix",
  112. input: []string{"alice:plaintext:user"},
  113. error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate",
  114. },
  115. {
  116. name: "invalid role",
  117. input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
  118. error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
  119. },
  120. {
  121. name: "empty username",
  122. input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
  123. error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
  124. },
  125. }
  126. for _, tt := range tests {
  127. t.Run(tt.name, func(t *testing.T) {
  128. result, err := parseUsers(tt.input)
  129. require.Error(t, err)
  130. require.Nil(t, result)
  131. assert.Contains(t, err.Error(), tt.error)
  132. })
  133. }
  134. }
  135. func TestParseAccess_Success(t *testing.T) {
  136. users := []*user.User{
  137. {Name: "alice", Role: user.RoleUser},
  138. {Name: "bob", Role: user.RoleUser},
  139. }
  140. tests := []struct {
  141. name string
  142. users []*user.User
  143. input []string
  144. expected map[string][]*user.Grant
  145. }{
  146. {
  147. name: "single access entry",
  148. users: users,
  149. input: []string{"alice:mytopic:read-write"},
  150. expected: map[string][]*user.Grant{
  151. "alice": {
  152. {
  153. TopicPattern: "mytopic",
  154. Permission: user.PermissionReadWrite,
  155. Provisioned: true,
  156. },
  157. },
  158. },
  159. },
  160. {
  161. name: "multiple access entries for same user",
  162. users: users,
  163. input: []string{
  164. "alice:topic1:read-only",
  165. "alice:topic2:write-only",
  166. },
  167. expected: map[string][]*user.Grant{
  168. "alice": {
  169. {
  170. TopicPattern: "topic1",
  171. Permission: user.PermissionRead,
  172. Provisioned: true,
  173. },
  174. {
  175. TopicPattern: "topic2",
  176. Permission: user.PermissionWrite,
  177. Provisioned: true,
  178. },
  179. },
  180. },
  181. },
  182. {
  183. name: "access for everyone",
  184. users: users,
  185. input: []string{"everyone:publictopic:read-only"},
  186. expected: map[string][]*user.Grant{
  187. user.Everyone: {
  188. {
  189. TopicPattern: "publictopic",
  190. Permission: user.PermissionRead,
  191. Provisioned: true,
  192. },
  193. },
  194. },
  195. },
  196. {
  197. name: "wildcard topic pattern",
  198. users: users,
  199. input: []string{"alice:topic*:read-write"},
  200. expected: map[string][]*user.Grant{
  201. "alice": {
  202. {
  203. TopicPattern: "topic*",
  204. Permission: user.PermissionReadWrite,
  205. Provisioned: true,
  206. },
  207. },
  208. },
  209. },
  210. {
  211. name: "empty input",
  212. users: users,
  213. input: []string{},
  214. expected: map[string][]*user.Grant{},
  215. },
  216. {
  217. name: "deny-all permission",
  218. users: users,
  219. input: []string{"alice:secretopic:deny-all"},
  220. expected: map[string][]*user.Grant{
  221. "alice": {
  222. {
  223. TopicPattern: "secretopic",
  224. Permission: user.PermissionDenyAll,
  225. Provisioned: true,
  226. },
  227. },
  228. },
  229. },
  230. }
  231. for _, tt := range tests {
  232. t.Run(tt.name, func(t *testing.T) {
  233. result, err := parseAccess(tt.users, tt.input)
  234. require.NoError(t, err)
  235. assert.Equal(t, tt.expected, result)
  236. })
  237. }
  238. }
  239. func TestParseAccess_Errors(t *testing.T) {
  240. users := []*user.User{
  241. {Name: "alice", Role: user.RoleUser},
  242. {Name: "admin", Role: user.RoleAdmin},
  243. }
  244. tests := []struct {
  245. name string
  246. users []*user.User
  247. input []string
  248. error string
  249. }{
  250. {
  251. name: "invalid format - too few parts",
  252. users: users,
  253. input: []string{"alice:topic"},
  254. error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'",
  255. },
  256. {
  257. name: "invalid format - too many parts",
  258. users: users,
  259. input: []string{"alice:topic:read:extra"},
  260. error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'",
  261. },
  262. {
  263. name: "user not provisioned",
  264. users: users,
  265. input: []string{"charlie:topic:read"},
  266. error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned",
  267. },
  268. {
  269. name: "admin user cannot have ACL entries",
  270. users: users,
  271. input: []string{"admin:topic:read"},
  272. error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries",
  273. },
  274. {
  275. name: "invalid topic pattern",
  276. users: users,
  277. input: []string{"alice:topic-with-invalid-chars!:read"},
  278. error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid",
  279. },
  280. {
  281. name: "invalid permission",
  282. users: users,
  283. input: []string{"alice:topic:invalid-permission"},
  284. error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid",
  285. },
  286. }
  287. for _, tt := range tests {
  288. t.Run(tt.name, func(t *testing.T) {
  289. result, err := parseAccess(tt.users, tt.input)
  290. require.Error(t, err)
  291. require.Nil(t, result)
  292. assert.Contains(t, err.Error(), tt.error)
  293. })
  294. }
  295. }
  296. func TestParseTokens_Success(t *testing.T) {
  297. users := []*user.User{
  298. {Name: "alice"},
  299. {Name: "bob"},
  300. }
  301. tests := []struct {
  302. name string
  303. users []*user.User
  304. input []string
  305. expected map[string][]*user.Token
  306. }{
  307. {
  308. name: "single token without label",
  309. users: users,
  310. input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"},
  311. expected: map[string][]*user.Token{
  312. "alice": {
  313. {
  314. Value: "tk_abcdefghijklmnopqrstuvwxyz123",
  315. Label: "",
  316. Provisioned: true,
  317. },
  318. },
  319. },
  320. },
  321. {
  322. name: "single token with label",
  323. users: users,
  324. input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"},
  325. expected: map[string][]*user.Token{
  326. "alice": {
  327. {
  328. Value: "tk_abcdefghijklmnopqrstuvwxyz123",
  329. Label: "My Phone",
  330. Provisioned: true,
  331. },
  332. },
  333. },
  334. },
  335. {
  336. name: "multiple tokens for same user",
  337. users: users,
  338. input: []string{
  339. "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
  340. "alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop",
  341. },
  342. expected: map[string][]*user.Token{
  343. "alice": {
  344. {
  345. Value: "tk_abcdefghijklmnopqrstuvwxyz123",
  346. Label: "Phone",
  347. Provisioned: true,
  348. },
  349. {
  350. Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
  351. Label: "Laptop",
  352. Provisioned: true,
  353. },
  354. },
  355. },
  356. },
  357. {
  358. name: "tokens for multiple users",
  359. users: users,
  360. input: []string{
  361. "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
  362. "bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet",
  363. },
  364. expected: map[string][]*user.Token{
  365. "alice": {
  366. {
  367. Value: "tk_abcdefghijklmnopqrstuvwxyz123",
  368. Label: "Phone",
  369. Provisioned: true,
  370. },
  371. },
  372. "bob": {
  373. {
  374. Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
  375. Label: "Tablet",
  376. Provisioned: true,
  377. },
  378. },
  379. },
  380. },
  381. {
  382. name: "empty input",
  383. users: users,
  384. input: []string{},
  385. expected: map[string][]*user.Token{},
  386. },
  387. }
  388. for _, tt := range tests {
  389. t.Run(tt.name, func(t *testing.T) {
  390. result, err := parseTokens(tt.users, tt.input)
  391. require.NoError(t, err)
  392. assert.Equal(t, tt.expected, result)
  393. })
  394. }
  395. }
  396. func TestParseTokens_Errors(t *testing.T) {
  397. users := []*user.User{
  398. {Name: "alice"},
  399. }
  400. tests := []struct {
  401. name string
  402. users []*user.User
  403. input []string
  404. error string
  405. }{
  406. {
  407. name: "invalid format - too few parts",
  408. users: users,
  409. input: []string{"alice"},
  410. error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'",
  411. },
  412. {
  413. name: "invalid format - too many parts",
  414. users: users,
  415. input: []string{"alice:token:label:extra:parts"},
  416. error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'",
  417. },
  418. {
  419. name: "user not provisioned",
  420. users: users,
  421. input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"},
  422. error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned",
  423. },
  424. {
  425. name: "invalid token format",
  426. users: users,
  427. input: []string{"alice:invalid-token"},
  428. error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token",
  429. },
  430. {
  431. name: "token too short",
  432. users: users,
  433. input: []string{"alice:tk_short"},
  434. error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token",
  435. },
  436. {
  437. name: "token without prefix",
  438. users: users,
  439. input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"},
  440. error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token",
  441. },
  442. }
  443. for _, tt := range tests {
  444. t.Run(tt.name, func(t *testing.T) {
  445. result, err := parseTokens(tt.users, tt.input)
  446. require.Error(t, err)
  447. require.Nil(t, result)
  448. assert.Contains(t, err.Error(), tt.error)
  449. })
  450. }
  451. }
  452. func TestCLI_Serve_Unix_Curl(t *testing.T) {
  453. sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
  454. configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
  455. go func() {
  456. app, _, _, _ := newTestApp()
  457. err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile})
  458. require.Nil(t, err)
  459. }()
  460. for i := 0; i < 40 && !util.FileExists(sockFile); i++ {
  461. time.Sleep(50 * time.Millisecond)
  462. }
  463. require.True(t, util.FileExists(sockFile))
  464. cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic")
  465. out, err := cmd.Output()
  466. require.Nil(t, err)
  467. m := toMessage(t, string(out))
  468. require.Equal(t, "this is a message", m.Message)
  469. }
  470. func TestCLI_Serve_WebSocket(t *testing.T) {
  471. port := 10000 + rand.Intn(20000)
  472. go func() {
  473. configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
  474. app, _, _, _ := newTestApp()
  475. err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)})
  476. require.Nil(t, err)
  477. }()
  478. test.WaitForPortUp(t, port)
  479. ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil)
  480. require.Nil(t, err)
  481. messageType, data, err := ws.ReadMessage()
  482. require.Nil(t, err)
  483. require.Equal(t, websocket.TextMessage, messageType)
  484. require.Equal(t, "open", toMessage(t, string(data)).Event)
  485. c := client.New(client.NewConfig())
  486. _, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message")
  487. require.Nil(t, err)
  488. messageType, data, err = ws.ReadMessage()
  489. require.Nil(t, err)
  490. require.Equal(t, websocket.TextMessage, messageType)
  491. m := toMessage(t, string(data))
  492. require.Equal(t, "my message", m.Message)
  493. require.Equal(t, "mytopic", m.Topic)
  494. }
  495. func TestIP_Host_Parsing(t *testing.T) {
  496. cases := map[string]string{
  497. "1.1.1.1": "1.1.1.1/32",
  498. "fd00::1234": "fd00::1234/128",
  499. "192.168.0.3/24": "192.168.0.0/24",
  500. "10.1.2.3/8": "10.0.0.0/8",
  501. "201:be93::4a6/21": "201:b800::/21",
  502. }
  503. for q, expectedAnswer := range cases {
  504. ips, err := parseIPHostPrefix(q)
  505. require.Nil(t, err)
  506. assert.Equal(t, 1, len(ips))
  507. assert.Equal(t, expectedAnswer, ips[0].String())
  508. }
  509. }
  510. func newEmptyFile(t *testing.T) string {
  511. filename := filepath.Join(t.TempDir(), "empty")
  512. require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
  513. return filename
  514. }