5 files changed,
94 insertions(+),
10 deletions(-)
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 }