Files
coder/codersdk/name_test.go
T
Atif Ali f79a36469e fix: relax OAuth2 app form validation for names and callback URLs (#29333)
## Problem

The OAuth2 application settings form rejects application names with
spaces and callbacks using native application schemes. A VS Code
extension client registered through DCR exposed this: `VS Code Coder
Extension` and `vscode://coder.coder-remote/oauth/callback` were already
stored, but the admin form prevented editing them.

## Fix

- Allow application names with spaces and punctuation, limited to 64
UTF-8 bytes on both client and server.
- Allow native callback schemes while rejecting dangerous schemes,
unsupported URNs, opaque callbacks such as `localhost:3000`, and empty
targets such as `vscode:`. HTTP(S) callbacks still require a host.
- Reuse the public-client registration policy when admins update public
clients, so `mailto`, `tel`, `sms`, external HTTP, and fragments remain
rejected.
- Trim surrounding whitespace from names and callbacks when submitting,
without stripping spaces while typing.
- Update the admin API request validators too, so accepted form values
can be saved.

The server remains authoritative for URL parsing. Public-client edits
reuse the existing DCR redirect validator using the stored client type;
confidential admin-created applications retain their existing HTTP
policy. Native host/path callbacks and the supported OOB URN remain
accepted.

## Validation

- Vitest component tests assert create/edit submission payloads,
whitespace normalization, Unicode byte limits, and rejection of unsafe
or malformed callbacks.
- Storybook stories cover accepted native-client values and invalid
callback states.
- Go tests cover name boundaries and accepted/rejected callback values
through both admin create and update requests.
- `make pre-commit` and targeted tests run before pushing; CI checked on
each revision.

<details>
<summary>Implementation decisions</summary>

- Replace the shared slug validator only for OAuth2 application names;
leave user, workspace, and template names unchanged.
- Share the backend's 64-byte name limit with the frontend through the
generated `OAuth2AppNameMaxBytes` constant.
- Reuse `codersdk.ValidateRedirectURIScheme` for common admin callback
validation. Public-client updates additionally call
`codersdk.ValidateRedirectURIs`; confidential admin HTTP behavior is
preserved.
- Normalize name and callback whitespace in the submitted payload rather
than on each keystroke.
- Keep behavior assertions in Vitest and use Storybook for visual
states.

</details>

Closes ENG-3342

> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑💻
2026-09-16 21:06:31 +05:00

329 lines
8.0 KiB
Go

package codersdk_test
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/testutil"
)
func TestUsernameValid(t *testing.T) {
t.Parallel()
// Tests whether usernames are valid or not.
testCases := []struct {
Username string
Valid bool
}{
{"1", true},
{"12", true},
{"123", true},
{"12345678901234567890", true},
{"123456789012345678901", true},
{"a", true},
{"a1", true},
{"a1b2", true},
{"a1b2c3d4e5f6g7h8i9j0", true},
{"a1b2c3d4e5f6g7h8i9j0k", true},
{"aa", true},
{"abc", true},
{"abcdefghijklmnopqrst", true},
{"abcdefghijklmnopqrstu", true},
{"wow-test", true},
{"", false},
{" ", false},
{" a", false},
{" a ", false},
{" 1", false},
{"1 ", false},
{" aa", false},
{"aa ", false},
{" 12", false},
{"12 ", false},
{" a1", false},
{"a1 ", false},
{" abcdefghijklmnopqrstu", false},
{"abcdefghijklmnopqrstu ", false},
{" 123456789012345678901", false},
{" a1b2c3d4e5f6g7h8i9j0k", false},
{"a1b2c3d4e5f6g7h8i9j0k ", false},
{"bananas_wow", false},
{"test--now", false},
{"123456789012345678901234567890123", false},
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
{"123456789012345678901234567890123123456789012345678901234567890123", false},
}
for _, testCase := range testCases {
t.Run(testCase.Username, func(t *testing.T) {
t.Parallel()
valid := codersdk.NameValid(testCase.Username)
require.Equal(t, testCase.Valid, valid == nil)
})
}
}
func TestTemplateDisplayNameValid(t *testing.T) {
t.Parallel()
// Tests whether display names are valid.
testCases := []struct {
Name string
Valid bool
}{
{"", true},
{"1", true},
{"12", true},
{"1 2", true},
{"123 456", true},
{"1234 678901234567890", true},
{"<b> </b>", true},
{"S", true},
{"a1", true},
{"a1K2", true},
{"!!!!1 ?????", true},
{"k\r\rm", true},
{"abcdefghijklmnopqrst", true},
{"Wow Test", true},
{"abcdefghijklmnopqrstu-", true},
{"a1b2c3d4e5f6g7h8i9j0k-", true},
{"BANANAS_wow", true},
{"test--now", true},
{"123456789012345678901234567890123", true},
{"1234567890123456789012345678901234567890123456789012345678901234", true},
{"-a1b2c3d4e5f6g7h8i9j0k", true},
{" ", false},
{"\t", false},
{"\r\r", false},
{"\t1 ", false},
{" a", false},
{"\ra ", false},
{" 1", false},
{"1 ", false},
{" aa", false},
{"aa\r", false},
{" 12", false},
{"12 ", false},
{"\fa1", false},
{"a1\t", false},
{"12345678901234567890123456789012345678901234567890123456789012345", false},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
t.Parallel()
valid := codersdk.DisplayNameValid(testCase.Name)
require.Equal(t, testCase.Valid, valid == nil)
})
}
}
func TestTemplateVersionNameValid(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Valid bool
}{
{"1", true},
{"12", true},
{"1_2", true},
{"1-2", true},
{"cray", true},
{"123_456", true},
{"123-456", true},
{"1234_678901234567890", true},
{"1234-678901234567890", true},
{"S", true},
{"a1", true},
{"a1K2", true},
{"fuzzy_bear3", true},
{"fuzzy-bear3", true},
{"v1.0.0", true},
{"heuristic_cray2", true},
{"", false},
{".v1", false},
{"v1..0", false},
{"4--4", false},
{"<b> </b>", false},
{"!!!!1 ?????", false},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
t.Parallel()
valid := codersdk.TemplateVersionNameValid(testCase.Name)
require.Equal(t, testCase.Valid, valid == nil)
})
}
}
func TestGeneratedTemplateVersionNameValid(t *testing.T) {
t.Parallel()
for i := 0; i < 1000; i++ {
name := testutil.GetRandomName(t)
err := codersdk.TemplateVersionNameValid(name)
require.NoError(t, err, "invalid template version name: %s", name)
}
}
func TestFrom(t *testing.T) {
t.Parallel()
testCases := []struct {
From string
Match string
}{
{"1", "1"},
{"kyle@kwc.io", "kyle"},
{"kyle+wow@kwc.io", "kylewow"},
{"kyle+testing", "kyletesting"},
{"kyle-testing", "kyle-testing"},
{"much.”more unusual”@example.com", "muchmoreunusual"},
// Cases where an invalid string is provided, and the result is a random name.
{"123456789012345678901234567890123", ""},
{"very.unusual.”@”.unusual.com@example.com", ""},
{"___@ok.com", ""},
{" something with spaces ", ""},
{"--test--", ""},
{"", ""},
}
for _, testCase := range testCases {
t.Run(testCase.From, func(t *testing.T) {
t.Parallel()
converted := codersdk.UsernameFrom(testCase.From)
t.Log(converted)
valid := codersdk.NameValid(converted)
require.True(t, valid == nil)
if testCase.Match == "" {
require.NotEqual(t, testCase.From, converted)
} else {
require.Equal(t, testCase.Match, converted)
}
})
}
}
func TestUserRealNameValid(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Valid bool
}{
{"", true},
{" a", false},
{"a ", false},
{" a ", false},
{"1", true},
{"A", true},
{"A1", true},
{".", true},
{"Mr Bean", true},
{"Severus Snape", true},
{"Prof. Albus Percival Wulfric Brian Dumbledore", true},
{"Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso", true},
{"Hector Ó hEochagáin", true},
{"Małgorzata Kalinowska-Iszkowska", true},
{"成龍", true},
{". .", true},
{"Lord Voldemort ", false},
{" Bellatrix Lestrange", false},
{" ", false},
{strings.Repeat("a", 128), true},
{strings.Repeat("a", 129), false},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
t.Parallel()
err := codersdk.UserRealNameValid(testCase.Name)
norm := codersdk.NormalizeRealUsername(testCase.Name)
normErr := codersdk.UserRealNameValid(norm)
assert.NoError(t, normErr)
assert.Equal(t, testCase.Valid, err == nil)
assert.Equal(t, testCase.Valid, norm == testCase.Name, "invalid name should be different after normalization")
})
}
}
func TestGroupNameValid(t *testing.T) {
t.Parallel()
random255String, err := cryptorand.String(255)
require.NoError(t, err, "failed to generate 255 random string")
random256String, err := cryptorand.String(256)
require.NoError(t, err, "failed to generate 256 random string")
testCases := []struct {
Name string
Valid bool
}{
{"", false},
{"my-group", true},
{"create", false},
{"new", false},
{"Lord Voldemort Team", false},
{random255String, true},
{random256String, false},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
t.Parallel()
err := codersdk.GroupNameValid(testCase.Name)
assert.Equal(
t,
testCase.Valid,
err == nil,
"Test case %s failed: expected valid=%t but got error: %v",
testCase.Name,
testCase.Valid,
err,
)
})
}
}
func TestOAuth2AppNameValid(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Valid bool
}{
{"", false},
{"a", true},
{"my-app", true},
// DCR-registered clients use spaces and other characters that the
// slug-style name validator rejects.
{"VS Code Coder Extension", true},
{"Cursor (MCP)", true},
{"new", true},
{"create", true},
{" leading", false},
{"trailing ", false},
{" ", false},
{strings.Repeat("a", codersdk.OAuth2AppNameMaxBytes), true},
{strings.Repeat("a", codersdk.OAuth2AppNameMaxBytes+1), false},
{strings.Repeat("é", codersdk.OAuth2AppNameMaxBytes/2), true},
{strings.Repeat("é", codersdk.OAuth2AppNameMaxBytes/2+1), false},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
t.Parallel()
err := codersdk.OAuth2AppNameValid(testCase.Name)
assert.Equal(t, testCase.Valid, err == nil,
"Test case %q failed: expected valid=%t but got error: %v",
testCase.Name, testCase.Valid, err)
if len(testCase.Name) > codersdk.OAuth2AppNameMaxBytes {
assert.EqualError(t, err, fmt.Sprintf("must be <= %d bytes", codersdk.OAuth2AppNameMaxBytes))
}
})
}
}