From a18734a50bfc0d55dba03a1b5e1ef6a20f6d97a7 Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 22 Dec 2025 08:55:10 -0700 Subject: [PATCH] feat(deeplink): add UUID validation and helpers Add ValidateUUID using github.com/google/uuid. Add Resource.Valid() and Resource.String() methods. Refactor ParseDeepLink/BuildDeepLink to use new helpers. UUIDs are now validated in both parse and build operations. Assisted-by: Claude Opus 4.5 via Amp --- deeplink.go | 65 +++++++++++++++++------ deeplink_test.go | 133 +++++++++++++++++++++++++++++++++++++++++------ go.mod | 2 + go.sum | 2 + 4 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 go.sum diff --git a/deeplink.go b/deeplink.go index 9b5a4746e1cd45683019cf057fcb9f9159ba49fd..1fed2d63e2f6b80d261418ecac29eebf73638b8d 100644 --- a/deeplink.go +++ b/deeplink.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "strings" + + "github.com/google/uuid" ) // Errors returned by deep link operations. @@ -16,7 +18,7 @@ var ( ErrInvalidDeepLink = errors.New("invalid deep link") // ErrInvalidResource is returned when a resource type is unknown. ErrInvalidResource = errors.New("invalid resource") - // ErrInvalidUUID is returned when the UUID portion is empty. + // ErrInvalidUUID is returned when the UUID portion is empty or malformed. ErrInvalidUUID = errors.New("invalid UUID") ) @@ -33,9 +35,38 @@ const ( ResourceNotebook Resource = "notebooks" ) +// Valid reports whether r is a known resource type. +func (r Resource) Valid() bool { + switch r { + case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook: + return true + } + + return false +} + +// String returns the resource type as a string. +func (r Resource) String() string { + return string(r) +} + +// ValidateUUID checks whether id is a valid UUID string. +func ValidateUUID(id string) error { + if id == "" { + return fmt.Errorf("%w: empty", ErrInvalidUUID) + } + + if _, err := uuid.Parse(id); err != nil { + return fmt.Errorf("%w: %q", ErrInvalidUUID, id) + } + + return nil +} + // ParseDeepLink extracts resource type and UUID from a Lunatask deep link // or plain UUID. Accepts "lunatask://tasks/uuid" or plain UUID strings. // When a plain UUID is provided, the resource type will be empty. +// The UUID is validated to be a well-formed UUID string. func ParseDeepLink(input string) (Resource, string, error) { if input == "" { return "", "", fmt.Errorf("%w: empty input", ErrInvalidDeepLink) @@ -44,7 +75,11 @@ func ParseDeepLink(input string) (Resource, string, error) { // Check for lunatask:// prefix const prefix = "lunatask://" if !strings.HasPrefix(input, prefix) { - // Treat as plain UUID + // Treat as plain UUID, validate format + if err := ValidateUUID(input); err != nil { + return "", "", err + } + return "", input, nil } @@ -57,31 +92,27 @@ func ParseDeepLink(input string) (Resource, string, error) { } resource := Resource(parts[0]) - uuid := parts[1] + id := parts[1] - // Validate resource type - switch resource { - case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook: - // valid - default: + if !resource.Valid() { return "", "", fmt.Errorf("%w: %q", ErrInvalidResource, resource) } - return resource, uuid, nil + if err := ValidateUUID(id); err != nil { + return "", "", err + } + + return resource, id, nil } // BuildDeepLink constructs a Lunatask deep link from resource type and ID. -// Returns "lunatask://resource/uuid". +// Returns "lunatask://resource/uuid". The ID is validated to be a well-formed UUID. func BuildDeepLink(resource Resource, id string) (string, error) { - if id == "" { - return "", ErrInvalidUUID + if err := ValidateUUID(id); err != nil { + return "", err } - // Validate resource type - switch resource { - case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook: - // valid - default: + if !resource.Valid() { return "", fmt.Errorf("%w: %q", ErrInvalidResource, resource) } diff --git a/deeplink_test.go b/deeplink_test.go index a2b1739450f87f3937fd8bb573206b8fb0ab3783..80e52c3ab601071f9ae976bccef20c53b3185568 100644 --- a/deeplink_test.go +++ b/deeplink_test.go @@ -14,6 +14,9 @@ import ( func TestParseDeepLink(t *testing.T) { t.Parallel() + validUUID := "12345678-1234-1234-1234-123456789012" + validUUID2 := "abcdef12-3456-7890-abcd-ef1234567890" + tests := []struct { name string input string @@ -22,23 +25,25 @@ func TestParseDeepLink(t *testing.T) { wantErr error }{ // Valid deep links - {"task_link", "lunatask://tasks/abc-123", lunatask.ResourceTask, "abc-123", nil}, - {"area_link", "lunatask://areas/def-456", lunatask.ResourceArea, "def-456", nil}, - {"goal_link", "lunatask://goals/ghi-789", lunatask.ResourceGoal, "ghi-789", nil}, - {"note_link", "lunatask://notes/jkl-012", lunatask.ResourceNote, "jkl-012", nil}, - {"person_link", "lunatask://people/mno-345", lunatask.ResourcePerson, "mno-345", nil}, - {"notebook_link", "lunatask://notebooks/pqr-678", lunatask.ResourceNotebook, "pqr-678", nil}, + {"task_link", "lunatask://tasks/" + validUUID, lunatask.ResourceTask, validUUID, nil}, + {"area_link", "lunatask://areas/" + validUUID, lunatask.ResourceArea, validUUID, nil}, + {"goal_link", "lunatask://goals/" + validUUID, lunatask.ResourceGoal, validUUID, nil}, + {"note_link", "lunatask://notes/" + validUUID, lunatask.ResourceNote, validUUID, nil}, + {"person_link", "lunatask://people/" + validUUID, lunatask.ResourcePerson, validUUID, nil}, + {"notebook_link", "lunatask://notebooks/" + validUUID, lunatask.ResourceNotebook, validUUID, nil}, // Plain UUIDs - {"plain_uuid", "abc-123-def-456", "", "abc-123-def-456", nil}, - {"uuid_only", "12345678-1234-1234-1234-123456789012", "", "12345678-1234-1234-1234-123456789012", nil}, + {"plain_uuid", validUUID2, "", validUUID2, nil}, + {"uuid_only", validUUID, "", validUUID, nil}, // Invalid inputs {"empty", "", "", "", lunatask.ErrInvalidDeepLink}, - {"invalid_resource", "lunatask://invalid/abc-123", "", "", lunatask.ErrInvalidResource}, + {"invalid_resource", "lunatask://invalid/" + validUUID, "", "", lunatask.ErrInvalidResource}, {"missing_uuid", "lunatask://tasks/", "", "", lunatask.ErrInvalidDeepLink}, - {"missing_resource", "lunatask:///abc-123", "", "", lunatask.ErrInvalidDeepLink}, + {"missing_resource", "lunatask:///" + validUUID, "", "", lunatask.ErrInvalidDeepLink}, {"malformed", "lunatask://tasks", "", "", lunatask.ErrInvalidDeepLink}, + {"invalid_uuid_in_link", "lunatask://tasks/not-a-uuid", "", "", lunatask.ErrInvalidUUID}, + {"invalid_plain_uuid", "not-a-valid-uuid", "", "", lunatask.ErrInvalidUUID}, } for _, testCase := range tests { @@ -75,6 +80,8 @@ func TestParseDeepLink(t *testing.T) { func TestBuildDeepLink(t *testing.T) { t.Parallel() + validUUID := "12345678-1234-1234-1234-123456789012" + tests := []struct { name string resource lunatask.Resource @@ -83,16 +90,17 @@ func TestBuildDeepLink(t *testing.T) { wantErr error }{ // Valid builds - {"task", lunatask.ResourceTask, "abc-123", "lunatask://tasks/abc-123", nil}, - {"area", lunatask.ResourceArea, "def-456", "lunatask://areas/def-456", nil}, - {"goal", lunatask.ResourceGoal, "ghi-789", "lunatask://goals/ghi-789", nil}, - {"note", lunatask.ResourceNote, "jkl-012", "lunatask://notes/jkl-012", nil}, - {"person", lunatask.ResourcePerson, "mno-345", "lunatask://people/mno-345", nil}, - {"notebook", lunatask.ResourceNotebook, "pqr-678", "lunatask://notebooks/pqr-678", nil}, + {"task", lunatask.ResourceTask, validUUID, "lunatask://tasks/" + validUUID, nil}, + {"area", lunatask.ResourceArea, validUUID, "lunatask://areas/" + validUUID, nil}, + {"goal", lunatask.ResourceGoal, validUUID, "lunatask://goals/" + validUUID, nil}, + {"note", lunatask.ResourceNote, validUUID, "lunatask://notes/" + validUUID, nil}, + {"person", lunatask.ResourcePerson, validUUID, "lunatask://people/" + validUUID, nil}, + {"notebook", lunatask.ResourceNotebook, validUUID, "lunatask://notebooks/" + validUUID, nil}, // Invalid inputs {"empty_id", lunatask.ResourceTask, "", "", lunatask.ErrInvalidUUID}, - {"invalid_resource", lunatask.Resource("invalid"), "abc-123", "", lunatask.ErrInvalidResource}, + {"invalid_resource", lunatask.Resource("invalid"), validUUID, "", lunatask.ErrInvalidResource}, + {"invalid_uuid", lunatask.ResourceTask, "not-a-uuid", "", lunatask.ErrInvalidUUID}, } for _, testCase := range tests { @@ -121,3 +129,94 @@ func TestBuildDeepLink(t *testing.T) { }) } } + +func TestValidateUUID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid_uuid", "12345678-1234-1234-1234-123456789012", false}, + {"valid_uuid_lowercase", "abcdef12-3456-7890-abcd-ef1234567890", false}, + {"valid_uuid_uppercase", "ABCDEF12-3456-7890-ABCD-EF1234567890", false}, + {"valid_uuid_mixed", "AbCdEf12-3456-7890-AbCd-Ef1234567890", false}, + {"empty", "", true}, + {"invalid_format", "not-a-uuid", true}, + {"too_short", "12345678-1234-1234-1234", true}, + {"too_long", "12345678-1234-1234-1234-1234567890123", true}, + {"missing_hyphens", "12345678123412341234123456789012", false}, // google/uuid accepts this + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + err := lunatask.ValidateUUID(testCase.input) + if (err != nil) != testCase.wantErr { + t.Errorf("ValidateUUID(%q) error = %v, wantErr %v", testCase.input, err, testCase.wantErr) + } + + if testCase.wantErr && err != nil && !errors.Is(err, lunatask.ErrInvalidUUID) { + t.Errorf("ValidateUUID(%q) error = %v, want wrapped ErrInvalidUUID", testCase.input, err) + } + }) + } +} + +func TestResource_Valid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource lunatask.Resource + want bool + }{ + {"area", lunatask.ResourceArea, true}, + {"goal", lunatask.ResourceGoal, true}, + {"task", lunatask.ResourceTask, true}, + {"note", lunatask.ResourceNote, true}, + {"person", lunatask.ResourcePerson, true}, + {"notebook", lunatask.ResourceNotebook, true}, + {"invalid", lunatask.Resource("invalid"), false}, + {"empty", lunatask.Resource(""), false}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + if got := testCase.resource.Valid(); got != testCase.want { + t.Errorf("Resource(%q).Valid() = %v, want %v", testCase.resource, got, testCase.want) + } + }) + } +} + +func TestResource_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource lunatask.Resource + want string + }{ + {"area", lunatask.ResourceArea, "areas"}, + {"goal", lunatask.ResourceGoal, "goals"}, + {"task", lunatask.ResourceTask, "tasks"}, + {"note", lunatask.ResourceNote, "notes"}, + {"person", lunatask.ResourcePerson, "people"}, + {"notebook", lunatask.ResourceNotebook, "notebooks"}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + if got := testCase.resource.String(); got != testCase.want { + t.Errorf("Resource.String() = %q, want %q", got, testCase.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 0de91209a6402bea7fd8ff7b61ee7687d784e4ca..02f58255c27ede7935316a7801a953831eabf0b6 100644 --- a/go.mod +++ b/go.mod @@ -5,3 +5,5 @@ module git.secluded.site/go-lunatask go 1.25.5 + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..7790d7c3e03900e267f0aade3acce649895b2246 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=