diff --git a/server/internal/data/users.sql.go b/server/internal/data/users.sql.go index 2b2a3d5..e9aef21 100644 --- a/server/internal/data/users.sql.go +++ b/server/internal/data/users.sql.go @@ -143,10 +143,16 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) { const listUsers = `-- name: ListUsers :many SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users +LIMIT $1 OFFSET $2 ` -func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { - rows, err := q.db.Query(ctx, listUsers) +type ListUsersParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, error) { + rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset) if err != nil { return nil, err } diff --git a/server/internal/service/auth.go b/server/internal/service/auth.go index d44246b..f77a066 100644 --- a/server/internal/service/auth.go +++ b/server/internal/service/auth.go @@ -67,7 +67,7 @@ type TokenStore interface { // Mockable user related database operations interface. type UserStore interface { CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) - ListUsers(ctx context.Context) ([]data.User, error) + ListUsers(ctx context.Context, arg data.ListUsersParams) ([]data.User, error) GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error) GetUserByUsername(ctx context.Context, username string) (data.User, error) UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error @@ -340,7 +340,11 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) { // Handler for listing all users stored in the database. Should only be allowed to be called by // administrator level users. func (rs authResource) List(w http.ResponseWriter, r *http.Request) { - users, err := rs.Users.ListUsers(r.Context()) + limit, offset := getPaginationParams(r) + users, err := rs.Users.ListUsers(r.Context(), data.ListUsersParams{ + Limit: limit, + Offset: offset, + }) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to retrieve users") return diff --git a/server/internal/service/notes.go b/server/internal/service/notes.go index a3e05cb..d1bb330 100644 --- a/server/internal/service/notes.go +++ b/server/internal/service/notes.go @@ -2,13 +2,9 @@ package service import ( "context" - "crypto/sha1" - "encoding/hex" "encoding/json" "fmt" "net/http" - "strconv" - "strings" "git.umbrella.haus/ae/notatest/internal/data" "github.com/go-chi/chi/v5" @@ -292,36 +288,3 @@ func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request) respondJSON(w, http.StatusOK, fullVersion) } - -// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to -// limit of 50 and offset 0 if parameters are missing/invalid. -func getPaginationParams(r *http.Request) (limit int32, offset int32) { - defaultLimit := 50 - defaultOffset := 0 - - limitStr := r.URL.Query().Get("limit") - if limitStr != "" { - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { - defaultLimit = l - } - } - - offsetStr := r.URL.Query().Get("offset") - if offsetStr != "" { - if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { - defaultOffset = o - } - } - - return int32(defaultLimit), int32(defaultOffset) -} - -// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and -// return the resulting hash as a string. -func sha1ContentHash(title, content string) string { - hashContent := title + content - hash := sha1.Sum([]byte(hashContent)) - hashStr := strings.ToUpper(hex.EncodeToString(hash[:])) - - return hashStr -} diff --git a/server/internal/service/util.go b/server/internal/service/util.go index d238644..99a152e 100644 --- a/server/internal/service/util.go +++ b/server/internal/service/util.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "regexp" + "strconv" "strings" "unicode/utf8" @@ -131,3 +132,36 @@ func validateUsername(username string) error { return nil } + +// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to +// limit of 50 and offset 0 if parameters are missing/invalid. +func getPaginationParams(r *http.Request) (limit int32, offset int32) { + defaultLimit := 50 + defaultOffset := 0 + + limitStr := r.URL.Query().Get("limit") + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + defaultLimit = l + } + } + + offsetStr := r.URL.Query().Get("offset") + if offsetStr != "" { + if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { + defaultOffset = o + } + } + + return int32(defaultLimit), int32(defaultOffset) +} + +// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and +// return the resulting hash as a string. +func sha1ContentHash(title, content string) string { + hashContent := title + content + hash := sha1.Sum([]byte(hashContent)) + hashStr := strings.ToUpper(hex.EncodeToString(hash[:])) + + return hashStr +} diff --git a/server/internal/service/util_test.go b/server/internal/service/util_test.go index 6da324a..99b4371 100644 --- a/server/internal/service/util_test.go +++ b/server/internal/service/util_test.go @@ -51,19 +51,19 @@ func TestValidatePassword(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { // Mock HTTP client if needed - if tt.mockHTTP != nil { + if tc.mockHTTP != nil { mockClient := new(MockHTTPClient) - tt.mockHTTP(mockClient) + tc.mockHTTP(mockClient) } - err := validatePassword(tt.password) - if tt.wantErr == "" { + err := validatePassword(tc.password) + if tc.wantErr == "" { assert.NoError(t, err) } else { - assert.ErrorContains(t, err, tt.wantErr) + assert.ErrorContains(t, err, tc.wantErr) } }) } @@ -103,10 +103,10 @@ func TestPasswordEntropyCalculation(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.password, func(t *testing.T) { - entropy := passwordvalidator.GetEntropy(tt.password) - assert.InDelta(t, tt.entropy, entropy, 1.0) + for _, tc := range tests { + t.Run(tc.password, func(t *testing.T) { + entropy := passwordvalidator.GetEntropy(tc.password) + assert.InDelta(t, tc.entropy, entropy, 1.0) }) } } @@ -139,13 +139,13 @@ func TestValidateUsername(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateUsername(tt.input) - if tt.wantErr == "" { + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateUsername(tc.input) + if tc.wantErr == "" { assert.NoError(t, err) } else { - assert.ErrorContains(t, err, tt.wantErr) + assert.ErrorContains(t, err, tc.wantErr) } }) } @@ -174,10 +174,119 @@ func TestNormalizeUsername(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeUsername(tt.input) - assert.Equal(t, tt.want, got) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := normalizeUsername(tc.input) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestGetPaginationParams(t *testing.T) { + tests := []struct { + name string + query string + expectedLimit int32 + expectedOffset int32 + }{ + { + name: "defaults", + query: "", + expectedLimit: 50, + expectedOffset: 0, + }, + { + name: "valid limit and offset", + query: "limit=25&offset=30", + expectedLimit: 25, + expectedOffset: 30, + }, + { + name: "invalid limit", + query: "limit=abc&offset=5", + expectedLimit: 50, + expectedOffset: 5, + }, + { + name: "limit zero", + query: "limit=0", + expectedLimit: 50, + expectedOffset: 0, + }, + { + name: "limit negative", + query: "limit=-5", + expectedLimit: 50, + expectedOffset: 0, + }, + { + name: "offset negative", + query: "offset=-10", + expectedLimit: 50, + expectedOffset: 0, + }, + { + name: "invalid offset", + query: "offset=xyz", + expectedLimit: 50, + expectedOffset: 0, + }, + { + name: "valid offset zero", + query: "offset=0", + expectedLimit: 50, + expectedOffset: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", "/test?"+tc.query, nil) + assert.Nil(t, err) + limit, offset := getPaginationParams(req) + assert.Equal(t, tc.expectedLimit, limit) + assert.Equal(t, tc.expectedOffset, offset) + }) + } +} + +func TestSHA1ContentHash(t *testing.T) { + tests := []struct { + name string + title string + content string + expected string + }{ + { + name: "empty strings", + title: "", + content: "", + expected: "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", + }, + { + name: "title only", + title: "hello", + content: "", + expected: "AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D", + }, + { + name: "content only", + title: "", + content: "world", + expected: "7C211433F02071597741E6FF5A8EA34789ABBF43", + }, + { + name: "both title and content", + title: "hello", + content: "world", + expected: "6ADFB183A4A2C94A2F92DAB5ADE762A47889A5A1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := sha1ContentHash(tc.title, tc.content) + assert.Equal(t, tc.expected, result) }) } } diff --git a/server/sql/queries/users.sql b/server/sql/queries/users.sql index a2b073b..5fc227f 100644 --- a/server/sql/queries/users.sql +++ b/server/sql/queries/users.sql @@ -9,7 +9,8 @@ VALUES ($1, $2, true) RETURNING *; -- name: ListUsers :many -SELECT * FROM users; +SELECT * FROM users +LIMIT $1 OFFSET $2; -- name: ListAdmins :many SELECT * FROM users