package cmd import ( "fmt" "math/rand" "os" "os/exec" "path/filepath" "testing" "time" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/test" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) func TestParseUsers_Success(t *testing.T) { tests := []struct { name string input []string expected []*user.User }{ { name: "single user", input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"}, expected: []*user.User{ { Name: "alice", Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S", Role: user.RoleUser, Provisioned: true, }, }, }, { name: "multiple users with different roles", input: []string{ "alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user", "bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin", }, expected: []*user.User{ { Name: "alice", Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S", Role: user.RoleUser, Provisioned: true, }, { Name: "bob", Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq", Role: user.RoleAdmin, Provisioned: true, }, }, }, { name: "empty input", input: []string{}, expected: []*user.User{}, }, { name: "user with special characters in name", input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"}, expected: []*user.User{ { Name: "alice.test+123@example.com", Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe", Role: user.RoleUser, Provisioned: true, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseUsers(tt.input) require.NoError(t, err) require.Len(t, result, len(tt.expected)) for i, expectedUser := range tt.expected { assert.Equal(t, expectedUser.Name, result[i].Name) assert.Equal(t, expectedUser.Hash, result[i].Hash) assert.Equal(t, expectedUser.Role, result[i].Role) assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned) } }) } } func TestParseUsers_Errors(t *testing.T) { tests := []struct { name string input []string error string }{ { name: "invalid format - too few parts", input: []string{"alice:hash"}, error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'", }, { name: "invalid format - too many parts", input: []string{"alice:hash:role:extra"}, error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'", }, { name: "invalid username", input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"}, error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid", }, { name: "invalid password hash - wrong prefix", input: []string{"alice:plaintext:user"}, error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate", }, { name: "invalid role", input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"}, error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'", }, { name: "empty username", input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"}, error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseUsers(tt.input) require.Error(t, err) require.Nil(t, result) assert.Contains(t, err.Error(), tt.error) }) } } func TestParseAccess_Success(t *testing.T) { users := []*user.User{ {Name: "alice", Role: user.RoleUser}, {Name: "bob", Role: user.RoleUser}, } tests := []struct { name string users []*user.User input []string expected map[string][]*user.Grant }{ { name: "single access entry", users: users, input: []string{"alice:mytopic:read-write"}, expected: map[string][]*user.Grant{ "alice": { { TopicPattern: "mytopic", Permission: user.PermissionReadWrite, Provisioned: true, }, }, }, }, { name: "multiple access entries for same user", users: users, input: []string{ "alice:topic1:read-only", "alice:topic2:write-only", }, expected: map[string][]*user.Grant{ "alice": { { TopicPattern: "topic1", Permission: user.PermissionRead, Provisioned: true, }, { TopicPattern: "topic2", Permission: user.PermissionWrite, Provisioned: true, }, }, }, }, { name: "access for everyone", users: users, input: []string{"everyone:publictopic:read-only"}, expected: map[string][]*user.Grant{ user.Everyone: { { TopicPattern: "publictopic", Permission: user.PermissionRead, Provisioned: true, }, }, }, }, { name: "wildcard topic pattern", users: users, input: []string{"alice:topic*:read-write"}, expected: map[string][]*user.Grant{ "alice": { { TopicPattern: "topic*", Permission: user.PermissionReadWrite, Provisioned: true, }, }, }, }, { name: "empty input", users: users, input: []string{}, expected: map[string][]*user.Grant{}, }, { name: "deny-all permission", users: users, input: []string{"alice:secretopic:deny-all"}, expected: map[string][]*user.Grant{ "alice": { { TopicPattern: "secretopic", Permission: user.PermissionDenyAll, Provisioned: true, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseAccess(tt.users, tt.input) require.NoError(t, err) assert.Equal(t, tt.expected, result) }) } } func TestParseAccess_Errors(t *testing.T) { users := []*user.User{ {Name: "alice", Role: user.RoleUser}, {Name: "admin", Role: user.RoleAdmin}, } tests := []struct { name string users []*user.User input []string error string }{ { name: "invalid format - too few parts", users: users, input: []string{"alice:topic"}, error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'", }, { name: "invalid format - too many parts", users: users, input: []string{"alice:topic:read:extra"}, error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'", }, { name: "user not provisioned", users: users, input: []string{"charlie:topic:read"}, error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned", }, { name: "admin user cannot have ACL entries", users: users, input: []string{"admin:topic:read"}, error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries", }, { name: "invalid topic pattern", users: users, input: []string{"alice:topic-with-invalid-chars!:read"}, error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid", }, { name: "invalid permission", users: users, input: []string{"alice:topic:invalid-permission"}, error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseAccess(tt.users, tt.input) require.Error(t, err) require.Nil(t, result) assert.Contains(t, err.Error(), tt.error) }) } } func TestParseTokens_Success(t *testing.T) { users := []*user.User{ {Name: "alice"}, {Name: "bob"}, } tests := []struct { name string users []*user.User input []string expected map[string][]*user.Token }{ { name: "single token without label", users: users, input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"}, expected: map[string][]*user.Token{ "alice": { { Value: "tk_abcdefghijklmnopqrstuvwxyz123", Label: "", Provisioned: true, }, }, }, }, { name: "single token with label", users: users, input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"}, expected: map[string][]*user.Token{ "alice": { { Value: "tk_abcdefghijklmnopqrstuvwxyz123", Label: "My Phone", Provisioned: true, }, }, }, }, { name: "multiple tokens for same user", users: users, input: []string{ "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone", "alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop", }, expected: map[string][]*user.Token{ "alice": { { Value: "tk_abcdefghijklmnopqrstuvwxyz123", Label: "Phone", Provisioned: true, }, { Value: "tk_zyxwvutsrqponmlkjihgfedcba987", Label: "Laptop", Provisioned: true, }, }, }, }, { name: "tokens for multiple users", users: users, input: []string{ "alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone", "bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet", }, expected: map[string][]*user.Token{ "alice": { { Value: "tk_abcdefghijklmnopqrstuvwxyz123", Label: "Phone", Provisioned: true, }, }, "bob": { { Value: "tk_zyxwvutsrqponmlkjihgfedcba987", Label: "Tablet", Provisioned: true, }, }, }, }, { name: "empty input", users: users, input: []string{}, expected: map[string][]*user.Token{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseTokens(tt.users, tt.input) require.NoError(t, err) assert.Equal(t, tt.expected, result) }) } } func TestParseTokens_Errors(t *testing.T) { users := []*user.User{ {Name: "alice"}, } tests := []struct { name string users []*user.User input []string error string }{ { name: "invalid format - too few parts", users: users, input: []string{"alice"}, error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'", }, { name: "invalid format - too many parts", users: users, input: []string{"alice:token:label:extra:parts"}, error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'", }, { name: "user not provisioned", users: users, input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"}, error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned", }, { name: "invalid token format", users: users, input: []string{"alice:invalid-token"}, error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token", }, { name: "token too short", users: users, input: []string{"alice:tk_short"}, error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token", }, { name: "token without prefix", users: users, input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"}, error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseTokens(tt.users, tt.input) require.Error(t, err) require.Nil(t, result) assert.Contains(t, err.Error(), tt.error) }) } } func TestCLI_Serve_Unix_Curl(t *testing.T) { sockFile := filepath.Join(t.TempDir(), "ntfy.sock") configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system go func() { app, _, _, _ := newTestApp() err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile}) require.Nil(t, err) }() for i := 0; i < 40 && !util.FileExists(sockFile); i++ { time.Sleep(50 * time.Millisecond) } require.True(t, util.FileExists(sockFile)) cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic") out, err := cmd.Output() require.Nil(t, err) m := toMessage(t, string(out)) require.Equal(t, "this is a message", m.Message) } func TestCLI_Serve_WebSocket(t *testing.T) { port := 10000 + rand.Intn(20000) go func() { configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system app, _, _, _ := newTestApp() err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)}) require.Nil(t, err) }() test.WaitForPortUp(t, port) ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil) require.Nil(t, err) messageType, data, err := ws.ReadMessage() require.Nil(t, err) require.Equal(t, websocket.TextMessage, messageType) require.Equal(t, "open", toMessage(t, string(data)).Event) c := client.New(client.NewConfig()) _, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message") require.Nil(t, err) messageType, data, err = ws.ReadMessage() require.Nil(t, err) require.Equal(t, websocket.TextMessage, messageType) m := toMessage(t, string(data)) require.Equal(t, "my message", m.Message) require.Equal(t, "mytopic", m.Topic) } func TestIP_Host_Parsing(t *testing.T) { cases := map[string]string{ "1.1.1.1": "1.1.1.1/32", "fd00::1234": "fd00::1234/128", "192.168.0.3/24": "192.168.0.0/24", "10.1.2.3/8": "10.0.0.0/8", "201:be93::4a6/21": "201:b800::/21", } for q, expectedAnswer := range cases { ips, err := parseIPHostPrefix(q) require.Nil(t, err) assert.Equal(t, 1, len(ips)) assert.Equal(t, expectedAnswer, ips[0].String()) } } func newEmptyFile(t *testing.T) string { filename := filepath.Join(t.TempDir(), "empty") require.Nil(t, os.WriteFile(filename, []byte{}, 0600)) return filename }