Compare commits

..

No commits in common. "ebd85c2e6af3d28a6b632f409dd43c37c0dd1d69" and "db2a010f540529f19fdc2cf8151ff5f4b86c957a" have entirely different histories.

6 changed files with 74 additions and 187 deletions

View File

@ -143,16 +143,10 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
const listUsers = `-- name: ListUsers :many const listUsers = `-- name: ListUsers :many
SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users
LIMIT $1 OFFSET $2
` `
type ListUsersParams struct { func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
Limit int32 `json:"limit"` rows, err := q.db.Query(ctx, listUsers)
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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -67,7 +67,7 @@ type TokenStore interface {
// Mockable user related database operations interface. // Mockable user related database operations interface.
type UserStore interface { type UserStore interface {
CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error)
ListUsers(ctx context.Context, arg data.ListUsersParams) ([]data.User, error) ListUsers(ctx context.Context) ([]data.User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error) GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error)
GetUserByUsername(ctx context.Context, username string) (data.User, error) GetUserByUsername(ctx context.Context, username string) (data.User, error)
UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error
@ -340,11 +340,7 @@ 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 // Handler for listing all users stored in the database. Should only be allowed to be called by
// administrator level users. // administrator level users.
func (rs authResource) List(w http.ResponseWriter, r *http.Request) { func (rs authResource) List(w http.ResponseWriter, r *http.Request) {
limit, offset := getPaginationParams(r) users, err := rs.Users.ListUsers(r.Context())
users, err := rs.Users.ListUsers(r.Context(), data.ListUsersParams{
Limit: limit,
Offset: offset,
})
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve users") respondError(w, http.StatusInternalServerError, "Failed to retrieve users")
return return

View File

@ -2,9 +2,13 @@ package service
import ( import (
"context" "context"
"crypto/sha1"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings"
"git.umbrella.haus/ae/notatest/internal/data" "git.umbrella.haus/ae/notatest/internal/data"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -69,7 +73,7 @@ func (rs notesResource) Routes() chi.Router {
r.Route(fmt.Sprintf("/{%s}", versionUUIDCtxParameter), func(r chi.Router) { r.Route(fmt.Sprintf("/{%s}", versionUUIDCtxParameter), func(r chi.Router) {
r.Use(uuidCtx(versionUUIDCtxParameter)) r.Use(uuidCtx(versionUUIDCtxParameter))
r.Use(versionCtx(rs.Notes)) // DB -> req. context (scoped version) r.Use(versionCtx(rs.Notes)) // DB -> req. context (scoped version)
r.Get("/", rs.GetFullVersion) // GET /notes/{id}/{id} - get specific version's contents r.Get("/", rs.GetFullVersion) // GET /notes/{id}/{id} - get
}) })
}) })
}) })
@ -114,14 +118,19 @@ func (rs *notesResource) Create(w http.ResponseWriter, r *http.Request) {
// Placeholder contents are decided server-side, so we need to inform the client of them via a // Placeholder contents are decided server-side, so we need to inform the client of them via a
// one-time-use DTO // one-time-use DTO
respondJSON(w, http.StatusCreated, map[string]string{ type response struct {
"title": initVersionTitle, Title string `json:"title"`
"content": initVersionContent, Content string `json:"content"`
}) }
res := response{
Title: initVersionTitle,
Content: initVersionContent,
}
respondJSON(w, http.StatusCreated, res)
} }
// Handler for listing the metadata of all user's available notes. This metadata contains IDs of
// the note and its owner, its title, and the time it was last updated.
func (rs *notesResource) ListMetadata(w http.ResponseWriter, r *http.Request) { func (rs *notesResource) ListMetadata(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userCtxKey{}).(*userClaims) user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok { if !ok {
@ -262,7 +271,6 @@ func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
- Increment `latest_version` - Increment `latest_version`
- Sync `current_version` with `latest_version` - Sync `current_version` with `latest_version`
*/ */
err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{ err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
NoteID: fullNote.NoteID, NoteID: fullNote.NoteID,
Title: *req.Title, Title: *req.Title,
@ -288,3 +296,36 @@ func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, fullVersion) 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
}

View File

@ -9,7 +9,6 @@ import (
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -132,36 +131,3 @@ func validateUsername(username string) error {
return nil 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
}

View File

@ -51,19 +51,19 @@ func TestValidatePassword(t *testing.T) {
}, },
} }
for _, tc := range tests { for _, tt := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Mock HTTP client if needed // Mock HTTP client if needed
if tc.mockHTTP != nil { if tt.mockHTTP != nil {
mockClient := new(MockHTTPClient) mockClient := new(MockHTTPClient)
tc.mockHTTP(mockClient) tt.mockHTTP(mockClient)
} }
err := validatePassword(tc.password) err := validatePassword(tt.password)
if tc.wantErr == "" { if tt.wantErr == "" {
assert.NoError(t, err) assert.NoError(t, err)
} else { } else {
assert.ErrorContains(t, err, tc.wantErr) assert.ErrorContains(t, err, tt.wantErr)
} }
}) })
} }
@ -103,10 +103,10 @@ func TestPasswordEntropyCalculation(t *testing.T) {
}, },
} }
for _, tc := range tests { for _, tt := range tests {
t.Run(tc.password, func(t *testing.T) { t.Run(tt.password, func(t *testing.T) {
entropy := passwordvalidator.GetEntropy(tc.password) entropy := passwordvalidator.GetEntropy(tt.password)
assert.InDelta(t, tc.entropy, entropy, 1.0) assert.InDelta(t, tt.entropy, entropy, 1.0)
}) })
} }
} }
@ -139,13 +139,13 @@ func TestValidateUsername(t *testing.T) {
}, },
} }
for _, tc := range tests { for _, tt := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := validateUsername(tc.input) err := validateUsername(tt.input)
if tc.wantErr == "" { if tt.wantErr == "" {
assert.NoError(t, err) assert.NoError(t, err)
} else { } else {
assert.ErrorContains(t, err, tc.wantErr) assert.ErrorContains(t, err, tt.wantErr)
} }
}) })
} }
@ -174,119 +174,10 @@ func TestNormalizeUsername(t *testing.T) {
}, },
} }
for _, tc := range tests { for _, tt := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := normalizeUsername(tc.input) got := normalizeUsername(tt.input)
assert.Equal(t, tc.want, got) assert.Equal(t, tt.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)
}) })
} }
} }

View File

@ -9,8 +9,7 @@ VALUES ($1, $2, true)
RETURNING *; RETURNING *;
-- name: ListUsers :many -- name: ListUsers :many
SELECT * FROM users SELECT * FROM users;
LIMIT $1 OFFSET $2;
-- name: ListAdmins :many -- name: ListAdmins :many
SELECT * FROM users SELECT * FROM users