From e290d1307fe1688251f52f1f860258e8b6c5474e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 31 Jul 2025 10:26:53 +0200 Subject: [PATCH] Tests --- cmd/serve.go | 5 +- cmd/serve_test.go | 452 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+), 2 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 3b1b4ef7..33d0ed78 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -636,8 +636,9 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok tokens[username] = make([]*user.Token, 0) } tokens[username] = append(tokens[username], &user.Token{ - Value: token, - Label: label, + Value: token, + Label: label, + Provisioned: true, }) } return tokens, nil diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 748adbd8..339423b6 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -14,9 +14,461 @@ import ( "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$abcdefghijklmnopqrstuvwxyz:user"}, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + }, + }, + { + name: "multiple users with different roles", + input: []string{ + "alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user", + "bob:$2b$10$abcdefghijklmnopqrstuvwxyz:admin", + }, + expected: []*user.User{ + { + Name: "alice", + Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", + Role: user.RoleUser, + Provisioned: true, + }, + { + Name: "bob", + Hash: "$2b$10$abcdefghijklmnopqrstuvwxyz", + 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:$2y$10$abcdefghijklmnopqrstuvwxyz:user"}, + expected: []*user.User{ + { + Name: "alice.test+123@example.com", + Hash: "$2y$10$abcdefghijklmnopqrstuvwxyz", + 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$abcdefghijklmnopqrstuvwxyz:user"}, + error: "invalid auth-users: alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", + }, + { + name: "invalid password hash - wrong prefix", + input: []string{"alice:plaintext:user"}, + error: "invalid auth-users: alice:plaintext:user, password hash but be a bcrypt hash, use 'ntfy user hash' to generate", + }, + { + name: "invalid role", + input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid"}, + error: "invalid auth-users: alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'", + }, + { + name: "empty username", + input: []string{":$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, + error: "invalid auth-users: :$2a$10$abcdefghijklmnopqrstuvwxyz: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