Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions provider/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package provider

import (
"context"
"fmt"
"net/url"
"regexp"

"github.com/google/uuid"
Expand Down Expand Up @@ -35,6 +37,31 @@ func appResource() *schema.Resource {
SchemaVersion: 1,

Description: "Use this resource to define shortcuts to access applications in a workspace.",
CustomizeDiff: func(ctx context.Context, diff *schema.ResourceDiff, i any) error {
external, ok := diff.GetOkExists("external")
if !ok || !external.(bool) {
return nil
}

urlVal, ok := diff.GetOkExists("url")
if !ok {
return nil
}

u, err := url.Parse(urlVal.(string))
if err != nil {
return fmt.Errorf("invalid URL %q: %w", urlVal.(string), err)
}

if u.Scheme == "" {
return fmt.Errorf(
"\"url\" must have a URL scheme (e.g. https://, vscode://, jetbrains-gateway://) when \"external\" is true, got %q",
urlVal.(string),
)
}
Comment on lines +46 to +61

return nil
},
CreateContext: func(c context.Context, resourceData *schema.ResourceData, i any) diag.Diagnostics {
resourceData.SetId(uuid.NewString())

Expand Down
100 changes: 100 additions & 0 deletions provider/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,106 @@ func TestApp(t *testing.T) {
}
})

t.Run("ExternalURLValidation", func(t *testing.T) {
t.Parallel()

cases := []struct {
name string
url string
external bool
expectError *regexp.Regexp
}{
{
name: "ExternalWithScheme",
url: "https://example.com",
external: true,
},
{
name: "ExternalWithVSCodeScheme",
url: "vscode://remote-ssh",
external: true,
},
{
name: "ExternalWithJetBrainsScheme",
url: "jetbrains-gateway://connection",
external: true,
},
{
name: "ExternalBareString",
url: "my-repo",
external: true,
expectError: regexp.MustCompile(`"url" must have a URL scheme`),
},
{
name: "ExternalRelativePath",
url: "/some/path",
external: true,
expectError: regexp.MustCompile(`"url" must have a URL scheme`),
},
{
name: "ExternalLocalhostNoScheme",
url: "localhost:8080",
external: true, // Go's url.Parse treats "localhost" as the scheme, so this passes
},
Comment on lines +616 to +619
{
name: "InternalBareString",
url: "my-repo",
external: false,
},
{
name: "InternalRelativePath",
url: "/some/path",
external: false,
},
{
name: "InternalLocalhost",
url: "http://localhost:8080",
external: false,
},
{
name: "InternalLocalhostNoScheme",
url: "localhost:8080",
external: false,
},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

externalLine := "external = false"
if tc.external {
externalLine = "external = true"
}

config := fmt.Sprintf(`
provider "coder" {}
resource "coder_agent" "dev" {
os = "linux"
arch = "amd64"
}
resource "coder_app" "test" {
agent_id = coder_agent.dev.id
slug = "test"
display_name = "Testing"
url = %q
%s
}
`, tc.url, externalLine)

resource.Test(t, resource.TestCase{
ProviderFactories: coderFactory(),
IsUnitTest: true,
Steps: []resource.TestStep{{
Config: config,
ExpectError: tc.expectError,
}},
})
})
}
})

t.Run("ConflictsWith", func(t *testing.T) {
t.Parallel()

Expand Down
30 changes: 30 additions & 0 deletions provider/helpers/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,36 @@ func ValidateURL(value any, label string) ([]string, []error) {
return nil, nil
}

// ValidateExternalURL validates that a URL intended for external use contains
// a scheme (e.g. http://, https://, vscode://, jetbrains-gateway://).
// Go's url.Parse is permissive and accepts bare strings like "my-repo" as
// relative URLs, but JavaScript's new URL() requires a scheme and will crash
// if one is not present.
func ValidateExternalURL(value any, label string) ([]string, []error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

label is only used as a detail added to the error message, all the tests only pass "url" as a label . its unneeded as input to this function .

all first param returns are nil, I think these were meant to represent warnings but there are none. neither return parameter needs to be an array . mostly good tests tho.

could also check that parsed.Host is not empty .

val, ok := value.(string)
if !ok {
return nil, []error{fmt.Errorf("expected %q to be a string", label)}
}

if val == "" {
return nil, nil
}

parsed, err := url.Parse(val)
if err != nil {
return nil, []error{err}
}

if parsed.Scheme == "" {
return nil, []error{fmt.Errorf(
"%q must have a URL scheme (e.g. https://, vscode://, jetbrains-gateway://), got %q",
label, val,
)}
}

return nil, nil
}

// WarnDirNotHome returns a warning if dir is set to a value other
// than $HOME, because this breaks Coder Desktop file sync. The dir
// attribute is deprecated and will be removed in a future release.
Expand Down
85 changes: 85 additions & 0 deletions provider/helpers/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,91 @@ func TestValidateURL(t *testing.T) {
}
}

func TestValidateExternalURL(t *testing.T) {
tests := []struct {
name string
value any
label string
expectError bool
errorContains string
}{
// Valid cases
{
name: "empty string",
value: "",
label: "url",
expectError: false,
},
{
name: "valid https URL",
value: "https://example.com",
label: "url",
expectError: false,
},
{
name: "valid http URL",
value: "http://localhost:8080",
label: "url",
expectError: false,
},
{
name: "vscode scheme",
value: "vscode://remote-ssh",
label: "url",
expectError: false,
},
{
name: "jetbrains scheme",
value: "jetbrains-gateway://connection",
label: "url",
expectError: false,
},
// Invalid cases
{
name: "bare string no scheme",
value: "my-repo",
label: "url",
expectError: true,
errorContains: "must have a URL scheme",
},
{
name: "relative path no scheme",
value: "/some/path",
label: "url",
expectError: true,
errorContains: "must have a URL scheme",
},
{
name: "localhost without scheme",
value: "localhost:8080",
label: "url",
expectError: false, // Go's url.Parse treats "localhost" as the scheme
},
Comment on lines +207 to +212
{
name: "non-string type",
value: 123,
label: "url",
expectError: true,
errorContains: "expected \"url\" to be a string",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
warnings, errors := ValidateExternalURL(tt.value, tt.label)

if tt.expectError {
require.Len(t, errors, 1, "expected an error but got none")
require.Contains(t, errors[0].Error(), tt.errorContains)
} else {
require.Empty(t, errors, "expected no errors but got: %v", errors)
}

require.Nil(t, warnings, "expected warnings to be nil but got: %v", warnings)
})
}
}

func TestWarnDirNotHome(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading