all repos

onasty @ 844e778

a one-time notes service
5 files changed, 94 insertions(+), 10 deletions(-)
fix: return "invalid slug" error to user (#189)

#164 introduced slug validation, but did not return the error to the user. This commit returns the error to the user instead of returning "internal error".

#188 didn't validate for unread and read slugs, so this PR did.
Author: Olexandr Smirnov ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-08-13 19:14:09 +0300
Parent: bc92b82
M e2e/apiv1_notes_test.go
···
        6
        6
         	"time"

      
        7
        7
         

      
        8
        8
         	"github.com/gofrs/uuid/v5"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/models"

      
        9
        10
         )

      
        10
        11
         

      
        11
        12
         type (

      ···
        67
        68
         			},

      
        68
        69
         		},

      
        69
        70
         		{

      
        
        71
        +			name: "invalid slug, with space",

      
        
        72
        +			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        73
        +				Slug:    e.uuid() + "fuker fuker",

      
        
        74
        +				Content: e.uuid(),

      
        
        75
        +			},

      
        
        76
        +			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        
        77
        +				e.Equal(http.StatusBadRequest, r.Code)

      
        
        78
        +			},

      
        
        79
        +		},

      
        
        80
        +		{

      
        
        81
        +			name: "invalid slug, with slash",

      
        
        82
        +			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        83
        +				Slug:    e.uuid() + "fuker/fuker",

      
        
        84
        +				Content: e.uuid(),

      
        
        85
        +			},

      
        
        86
        +			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        
        87
        +				e.Equal(http.StatusBadRequest, r.Code)

      
        
        88
        +			},

      
        
        89
        +		},

      
        
        90
        +		{

      
        
        91
        +			name: "invalid slug, 'read'",

      
        
        92
        +			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        93
        +				Slug:    "read",

      
        
        94
        +				Content: e.uuid(),

      
        
        95
        +			},

      
        
        96
        +			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        
        97
        +				e.Equal(r.Code, http.StatusBadRequest)

      
        
        98
        +

      
        
        99
        +				var body errorResponse

      
        
        100
        +				e.readBodyAndUnjsonify(r.Body, &body)

      
        
        101
        +

      
        
        102
        +				e.Equal(models.ErrNoteSlugIsAlreadyInUse.Error(), body.Message)

      
        
        103
        +			},

      
        
        104
        +		},

      
        
        105
        +		{

      
        
        106
        +			name: "invalid slug, 'unread'",

      
        
        107
        +			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        108
        +				Slug:    "unread",

      
        
        109
        +				Content: e.uuid(),

      
        
        110
        +			},

      
        
        111
        +			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        
        112
        +				e.Equal(r.Code, http.StatusBadRequest)

      
        
        113
        +

      
        
        114
        +				var body errorResponse

      
        
        115
        +				e.readBodyAndUnjsonify(r.Body, &body)

      
        
        116
        +

      
        
        117
        +				e.Equal(models.ErrNoteSlugIsAlreadyInUse.Error(), body.Message)

      
        
        118
        +			},

      
        
        119
        +		},

      
        
        120
        +		{

      
        
        121
        +			name: "slug provided but empty",

      
        
        122
        +			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        123
        +				Slug:    "",

      
        
        124
        +				Content: e.uuid(),

      
        
        125
        +			},

      
        
        126
        +			assert: func(r *httptest.ResponseRecorder, inp apiv1NoteCreateRequest) {

      
        
        127
        +				e.Equal(r.Code, http.StatusCreated)

      
        
        128
        +

      
        
        129
        +				var body apiv1NoteCreateResponse

      
        
        130
        +				e.readBodyAndUnjsonify(r.Body, &body)

      
        
        131
        +

      
        
        132
        +				dbNote := e.getNoteBySlug(body.Slug)

      
        
        133
        +				e.NotEmpty(dbNote)

      
        
        134
        +				e.Equal(inp.Content, dbNote.Content)

      
        
        135
        +			},

      
        
        136
        +		},

      
        
        137
        +		{

      
        70
        138
         			name: "set password",

      
        71
        139
         			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        72
        140
         				Content:  e.uuid(),

      
        73
        141
         				Password: e.uuid(),

      
        74
        142
         			},

      
        75
        143
         			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        76
        
        -				e.Equal(r.Code, http.StatusCreated)

      
        
        144
        +				e.Equal(http.StatusCreated, r.Code)

      
        77
        145
         			},

      
        78
        146
         		},

      
        79
        147
         		{

      
M internal/models/note.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"errors"

      
        5
        
        -	"strings"

      
        
        5
        +	"regexp"

      
        6
        6
         	"time"

      
        7
        7
         

      
        8
        8
         	"github.com/gofrs/uuid/v5"

      
        9
        9
         )

      
        
        10
        +

      
        
        11
        +// read and unread are not allowed because those slugs might and will be interpreted as api routes

      
        
        12
        +var notAllowedSlugs = map[string]struct{}{

      
        
        13
        +	"read":   {},

      
        
        14
        +	"unread": {},

      
        
        15
        +}

      
        10
        16
         

      
        11
        17
         var (

      
        12
        18
         	ErrNoteContentIsEmpty     = errors.New("note: content is empty")

      ···
        27
        33
         	ExpiresAt            time.Time

      
        28
        34
         }

      
        29
        35
         

      
        
        36
        +var slugPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)

      
        
        37
        +

      
        30
        38
         func (n Note) Validate() error {

      
        31
        39
         	if n.Content == "" {

      
        32
        40
         		return ErrNoteContentIsEmpty

      
        33
        41
         	}

      
        34
        42
         

      
        35
        
        -	if n.Slug == "" || strings.Contains(n.Slug, " ") {

      
        
        43
        +	if !slugPattern.MatchString(n.Slug) {

      
        36
        44
         		return ErrNoteSlugIsInvalid

      
        37
        45
         	}

      
        38
        46
         

      
        39
        47
         	if n.IsExpired() {

      
        40
        48
         		return ErrNoteExpired

      
        
        49
        +	}

      
        
        50
        +

      
        
        51
        +	if _, exists := notAllowedSlugs[n.Slug]; exists {

      
        
        52
        +		return ErrNoteSlugIsAlreadyInUse

      
        41
        53
         	}

      
        42
        54
         

      
        43
        55
         	return nil

      
M internal/models/note_test.go
···
        44
        44
         		assert.EqualError(t, n.Validate(), ErrNoteExpired.Error())

      
        45
        45
         	})

      
        46
        46
         	t.Run("should fail if slug is empty", func(t *testing.T) {

      
        47
        
        -		n := Note{Content: "the content", Slug: ""}

      
        
        47
        +		n := Note{Content: "the content", Slug: " "}

      
        48
        48
         		assert.EqualError(t, n.Validate(), ErrNoteSlugIsInvalid.Error())

      
        49
        49
         	})

      
        50
        
        -	t.Run("should fail if slug is empty", func(t *testing.T) {

      
        51
        
        -		n := Note{Content: "the content", Slug: " "}

      
        
        50
        +	t.Run("should fail if slug has '/'", func(t *testing.T) {

      
        
        51
        +		n := Note{Content: "the content", Slug: "asdf/asdf"}

      
        52
        52
         		assert.EqualError(t, n.Validate(), ErrNoteSlugIsInvalid.Error())

      
        
        53
        +	})

      
        
        54
        +	t.Run("should fail if slug one of not allowed slugs", func(t *testing.T) {

      
        
        55
        +		for notAllowedSlug := range notAllowedSlugs {

      
        
        56
        +			n := Note{Content: "the content", Slug: notAllowedSlug}

      
        
        57
        +			assert.EqualError(t, n.Validate(), ErrNoteSlugIsAlreadyInUse.Error())

      
        
        58
        +		}

      
        53
        59
         	})

      
        54
        60
         }

      
        55
        61
         

      
M internal/transport/http/apiv1/apiv1.go
···
        75
        75
         		authorized := note.Group("", a.authorizedMiddleware)

      
        76
        76
         		{

      
        77
        77
         			authorized.GET("", a.getNotesHandler)

      
        78
        
        -

      
        79
        
        -			// FIXME: those links make slugs `read` and `unread` unavailable

      
        80
        78
         			authorized.GET("/read", a.getReadNotesHandler)

      
        81
        79
         			authorized.GET("/unread", a.getUnReadNotesHandler)

      
        82
        
        -

      
        83
        80
         			authorized.PATCH(":slug/expires", a.updateNoteHandler)

      
        84
        81
         			authorized.PATCH(":slug/password", a.setNotePasswordHandler)

      
        85
        82
         			authorized.DELETE(":slug", a.deleteNoteHandler)

      
M internal/transport/http/apiv1/response.go
···
        32
        32
         		// notes

      
        33
        33
         		errors.Is(err, notesrv.ErrNotePasswordNotProvided) ||

      
        34
        34
         		errors.Is(err, models.ErrNoteContentIsEmpty) ||

      
        35
        
        -		errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) {

      
        
        35
        +		errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) ||

      
        
        36
        +		errors.Is(err, models.ErrNoteSlugIsInvalid) {

      
        36
        37
         		newError(c, http.StatusBadRequest, err.Error())

      
        37
        38
         		return

      
        38
        39
         	}