all repos

onasty @ c24198a

a one-time notes service
5 files changed, 50 insertions(+), 7 deletions(-)
fix(api): prevent note creation when BurnBeforeExpiration is set without ExpiresAt; handle empty slug correctly (#197)

* fix(api): `BurnBeforeExpiration` cannot be used if `ExpiresAt` is not provided

* fix(api): validate slug only if it was provided

* feat(web): don't allow use to set `BurnBeforeExpiration` if `ExpiresAt` isn't set

* fix: grammar
Author: Olexandr Smirnov olexsmir@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-08-27 16:12:11 +0300
Parent: 3b5e67f
M e2e/apiv1_notes_test.go
···
        135
        135
         			},

      
        136
        136
         		},

      
        137
        137
         		{

      
        
        138
        +			name: "burn before expiration, but without expiration time",

      
        
        139
        +			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        140
        +				Content:              e.uuid(),

      
        
        141
        +				BurnBeforeExpiration: true,

      
        
        142
        +			},

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

      
        
        144
        +				var body errorResponse

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

      
        
        146
        +

      
        
        147
        +				e.Equal(models.ErrNoteCannotBeBurnt.Error(), body.Message)

      
        
        148
        +			},

      
        
        149
        +		},

      
        
        150
        +		{

      
        138
        151
         			name: "set password",

      
        139
        152
         			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        140
        153
         				Content:  e.uuid(),

      
M internal/models/note.go
···
        18
        18
         	ErrNoteContentIsEmpty     = errors.New("note: content is empty")

      
        19
        19
         	ErrNoteSlugIsAlreadyInUse = errors.New("note: slug is already in use")

      
        20
        20
         	ErrNoteSlugIsInvalid      = errors.New("note: slug is invalid")

      
        21
        
        -	ErrNoteExpired            = errors.New("note: expired")

      
        22
        
        -	ErrNoteNotFound           = errors.New("note: not found")

      
        
        21
        +	ErrNoteCannotBeBurnt      = errors.New(

      
        
        22
        +		"note: cannot be burnt before expiration if expiration time is not provided",

      
        
        23
        +	)

      
        
        24
        +	ErrNoteExpired  = errors.New("note: expired")

      
        
        25
        +	ErrNoteNotFound = errors.New("note: not found")

      
        23
        26
         )

      
        24
        27
         

      
        25
        28
         type Note struct {

      ···
        40
        43
         		return ErrNoteContentIsEmpty

      
        41
        44
         	}

      
        42
        45
         

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

      
        
        46
        +	if n.Slug != "" && !slugPattern.MatchString(n.Slug) {

      
        44
        47
         		return ErrNoteSlugIsInvalid

      
        45
        48
         	}

      
        46
        49
         

      
        47
        50
         	if n.IsExpired() {

      
        48
        51
         		return ErrNoteExpired

      
        
        52
        +	}

      
        
        53
        +

      
        
        54
        +	if n.BurnBeforeExpiration && n.ExpiresAt.IsZero() {

      
        
        55
        +		return ErrNoteCannotBeBurnt

      
        49
        56
         	}

      
        50
        57
         

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

      
M internal/models/note_test.go
···
        43
        43
         		}

      
        44
        44
         		assert.EqualError(t, n.Validate(), ErrNoteExpired.Error())

      
        45
        45
         	})

      
        
        46
        +	t.Run("should fail if burn before expiration is set, and expiration time is not",

      
        
        47
        +		func(t *testing.T) {

      
        
        48
        +			n := Note{

      
        
        49
        +				Content:              "content",

      
        
        50
        +				BurnBeforeExpiration: true,

      
        
        51
        +			}

      
        
        52
        +

      
        
        53
        +			assert.EqualError(t, n.Validate(), ErrNoteCannotBeBurnt.Error())

      
        
        54
        +		},

      
        
        55
        +	)

      
        
        56
        +	t.Run("should not fail if slug is not provided", func(t *testing.T) {

      
        
        57
        +		n := Note{Content: "the content"}

      
        
        58
        +		assert.NoError(t, n.Validate())

      
        
        59
        +	})

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

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

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

      
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.ErrNoteCannotBeBurnt) ||

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

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

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

      
M web/src/Pages/Home_.elm
···
        287
        287
                         ]

      
        288
        288
                     , H.div [ A.class "space-y-6" ]

      
        289
        289
                         [ viewExpirationTimeSelector

      
        290
        
        -                , viewBurnBeforeExpirationCheckbox

      
        
        290
        +                , viewBurnBeforeExpirationCheckbox (isCheckBoxDisabled model.expirationTime)

      
        291
        291
                         ]

      
        292
        292
                     ]

      
        293
        293
                 , H.div [ A.class "flex justify-end" ]

      ···
        341
        341
                 ]

      
        342
        342
         

      
        343
        343
         

      
        344
        
        -viewBurnBeforeExpirationCheckbox : Html Msg

      
        345
        
        -viewBurnBeforeExpirationCheckbox =

      
        
        344
        +viewBurnBeforeExpirationCheckbox : Bool -> Html Msg

      
        
        345
        +viewBurnBeforeExpirationCheckbox isDisabled =

      
        346
        346
             H.div [ A.class "space-y-2" ]

      
        347
        347
                 [ H.div [ A.class "flex items-start space-x-3" ]

      
        348
        348
                     [ H.input

      ···
        350
        350
                         , A.id "burn"

      
        351
        351
                         , A.type_ "checkbox"

      
        352
        352
                         , A.class "mt-1 h-4 w-4 text-black border-gray-300 rounded focus:ring-black focus:ring-2"

      
        
        353
        +                , A.disabled isDisabled

      
        353
        354
                         ]

      
        354
        355
                         []

      
        355
        356
                     , H.div [ A.class "flex-1" ]

      
        356
        357
                         [ H.label [ A.for "burn", A.class "block text-sm font-medium text-gray-700 cursor-pointer" ]

      
        357
        
        -                    [ H.text "Don't delete note until expiration time, even if it has been read it" ]

      
        
        358
        +                    [ H.text "Keep the note until its expiration time, even if it has already been read." ]

      
        
        359
        +                , H.span [ A.class "block text-sm font-medium text-gray-500 cursor-pointer" ]

      
        
        360
        +                    [ H.text "Can only be used if expiration time is set" ]

      
        358
        361
                         ]

      
        359
        362
                     ]

      
        360
        363
                 ]

      
        
        364
        +

      
        
        365
        +

      
        
        366
        +isCheckBoxDisabled : Maybe Int -> Bool

      
        
        367
        +isCheckBoxDisabled expirationTime =

      
        
        368
        +    expirationTime == Nothing

      
        361
        369
         

      
        362
        370
         

      
        363
        371
         isFormDisabled : Model -> Bool