Files
coder/codersdk/deployment_test.go
Thomas Kosiewski f923f4a525 chore!: remove the chat automations experiment (#30412)
Removes the experimental Chat Automations feature (`chat-automations` experiment) from main. This reverts the 16-PR stack that added it and adds two forward migrations that undo its schema and credential changes.

> [!WARNING]
> **Upgrade consequences**
>
> - **Automation data is deleted.** Migration 000615 drops the `chat_automations` table and deletes every queued message an automation delivered. Chats, their messages and ordinary queued messages stay. Messages lose their automation provenance (`automation_id`, `input_id`).
> - **Automation-only credentials are revoked.** Migration 000616 deletes API keys, OAuth2 codes and OAuth2 tokens whose only scopes (or allow-list entries) were `chat_automation:*`. Mixed credentials keep their other scopes. Custom roles lose their `chat_automation` permissions. OAuth2 apps that allowed only `chat_automation` scopes get the whitespace allowlist that grants nothing, as in 000595.
> - **A coordinated restart is required.** Stop all old replicas before the new version runs the migrations. Old replicas read the dropped table and columns, so a rolling deploy is not supported.
> - **Remove `--chat-max-automations-per-owner` before upgrading.** The flag, the YAML key `chat.maxAutomationsPerOwner` and the env var `CODER_CHAT_MAX_AUTOMATIONS_PER_OWNER` are removed. A deployment that still sets the flag or the YAML key will not start (`unknown flag` / `unknown option`). The env var is ignored. This includes dogfood. No release or tag contains the flag.
> - **Down migrations restore the schema only.** 000616 down and 000615 down recreate the 000611 schema with empty tables. They do not restore deleted automations, webhook secrets, provenance, queued deliveries, revoked credentials, stripped permissions or the deleted experiment rule.

## What is removed

- The `chat-automations` experiment and its stored runtime rule (`experiment_rule:chat-automations`).
- The `--chat-max-automations-per-owner` deployment option.
- The `chat_automation` RBAC resource, its member and chatd grants, and the `chat_automation:*` API key scopes.
- The automation sqlc queries, dbauthz wrappers and audit table entries.
- The management routes under `/api/experimental/organizations/{organization}/chat-automations`, and the webhook route `POST /api/experimental/chat-automations/{automation}/events` with its rate limiter.
- The chatd management service, webhook publish, schedule loop, the `manage_automations` tool, and the chatstate admission seam and queue promotion guard that only automations used.
- The Agents Automations page, editor, nav entry, chat labels, the Manage automations toggle and the API client.
- The `archived:any` chat search value that the automations "View chats" dialog added.
- `docs/ai-coder/agents/automations.md`, its manifest entry, the glossary entry and the related rows.

Reverted PRs: #30085, #30096, #30112, #30117, #30127, #30134, #30160, #30166, #30167, #30171, #30187, #30207, #30214, #30217, #30219, #30220.

## What stays, and why

- Migrations 000609, 000610 and 000611, their fixture, and the #30297 lock fix with `migration000609_test.go` stay unchanged. Deployments such as dogfood already ran them.
- New migration **000615** reverses the schema changes of 000609 and 000611 against the current schema. It uses the same short-wait lock-and-retry block as 000609, and it rebuilds `chats_expanded` from main's current definition minus the two automation columns. New migration **000616** follows the 000595 Tasks removal for credentials and recreates `api_key_scope` without the five `chat_automation:*` values. It keeps the `chat_project_memory:*` values from 000612.
- The `chat_automation` value stays in the `resource_type` enum, and `codersdk.ResourceTypeChatAutomation` stays. Old audit log rows stay readable, as with `task` after the Tasks removal.
- The 000615 delete fires the existing queue-version trigger, so `queue_version` moves to `snapshot_version` for each affected chat. `snapshot_version` is not bumped: the coordinated restart restarts every stream loop from an empty state, and the site does not read the version fields.
- `coderd/x/chatd/ARCHITECTURE.md` drops the sections the stack added and gets back its pre-stack wording, merged with the later main edits to the same file.

## Notes for reviewers

- #30379 (chat project page) merged while this PR was in progress. It passed `manageAutomationsEnabled` in `AgentCreatePage.test.tsx` and listed an `automations` route in `AgentsPageLayout.test.tsx`. Later main commits also added automation props in `ConversationTimeline.test.tsx` and `ConversationTimelineWorkingBlocks.stories.tsx`. This PR removes all of them.
- Remaining `automation` matches under `coderd`, `codersdk`, `enterprise`, `site/src` and `docs` are intended:
  - migrations 000609 to 000611, their fixture and `migration000609_test.go`;
  - the new 000615 and 000616 migrations and `migration000615_test.go`;
  - the `chat_automation` resource type in `dump.sql`, `models.go`, `codersdk/audit.go`, the generated API docs, `swagger.json` and `typesGenerated.ts`;
  - unrelated uses of the word "automation" (docs, `chatlabels_test.go`, `ratelimit.go`, `autostop.go`, `subagent_catalog.go`, `WorkspaceSharingSection.tsx`, `AgentChatPage.stories.tsx`).

Remote UAT on an isolated dev deployment passed. It upgraded a populated pre-removal database, then checked chats, queued messages, tokens, the 404 routes, the removed page and nav entry, and the experiments list.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_

![Agents sidebar after the upgrade, with no Automations entry](https://github.com/user-attachments/assets/bd446035-7fc8-4ca4-a735-3a4f36380738)
2026-10-11 19:57:49 +02:00

2198 lines
67 KiB
Go

package codersdk_test
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
type exclusion struct {
flag bool
env bool
yaml bool
}
// TestExperimentDisplayNames pins the display name of every known experiment.
// DisplayName falls back to a title-cased key, so an experiment missing its
// case renders a guess in the product UI instead of failing loudly. The length
// assertion forces a new experiment to be named here as well.
func TestExperimentDisplayNames(t *testing.T) {
t.Parallel()
expected := map[codersdk.Experiment]string{
codersdk.ExperimentExample: "Example Experiment",
codersdk.ExperimentNotifications: "SMTP and Webhook Notifications",
codersdk.ExperimentWorkspaceUsage: "Workspace Usage Tracking",
codersdk.ExperimentMCPServerHTTP: "MCP HTTP Server Functionality",
codersdk.ExperimentMCPToolSearch: "MCP Tool Search",
codersdk.ExperimentNoNATSPubsub: "No NATS Pubsub",
codersdk.ExperimentWorkspaceBuildUpdates: "Workspace Build Updates Channel",
codersdk.ExperimentWorkspaceCapableLicensing: "Workspace-Capable Licensing",
codersdk.ExperimentAIGatewaySeatExclusion: "AI Gateway Seat Exclusion",
codersdk.ExperimentAIGatewayReverseProxy: "AI Gateway Reverse Proxy",
codersdk.ExperimentChatProjects: "Chat Projects",
codersdk.ExperimentChatAdvisor: "Chat Advisor",
codersdk.ExperimentChatVirtualDesktop: "Chat Virtual Desktop",
codersdk.ExperimentAgentLifecycleHooks: "Agent Lifecycle Hooks",
codersdk.ExperimentChatInlineMCPServers: "Chat Inline MCP Servers",
codersdk.ExperimentEnableAIWorkspaceDebug: "AI Workspace Debugging",
codersdk.ExperimentChatStageMetrics: "Chat Stage Metrics",
}
require.Len(t, expected, len(codersdk.ExperimentsKnown))
for _, experiment := range codersdk.ExperimentsKnown {
displayName, ok := expected[experiment]
require.Truef(t, ok, "experiment %q has no expected display name", experiment)
require.Equal(t, displayName, experiment.DisplayName())
}
}
func TestDeploymentValues_HighlyConfigurable(t *testing.T) {
t.Parallel()
// This test ensures that every deployment option has
// a corresponding Flag, Env, and YAML name, unless explicitly excluded.
excludes := map[string]exclusion{
// These are used to configure YAML support itself, so
// they make no sense within the YAML file.
"Config Path": {
yaml: true,
},
"Write Config": {
yaml: true,
env: true,
},
// Dangerous values? Not sure we should help users
// persistent their configuration.
"DANGEROUS: Allow Path App Sharing": {
yaml: true,
},
"DANGEROUS: Allow Site Owners to Access Path Apps": {
yaml: true,
},
// Secrets
"Trace Honeycomb API Key": {
yaml: true,
},
"OAuth2 GitHub Client Secret": {
yaml: true,
},
"OIDC Client Secret": {
yaml: true,
},
"Postgres Connection URL": {
yaml: true,
},
"SCIM API Key": {
yaml: true,
},
"External Token Encryption Keys": {
yaml: true,
},
"External Auth Providers": {
// Technically External Auth Providers can be provided through the env,
// but bypassing serpent. See cli.ReadExternalAuthProvidersFromEnv.
flag: true,
env: true,
},
"Provisioner Daemon Pre-shared Key (PSK)": {
yaml: true,
},
"Email Auth: Password": {
yaml: true,
},
"Chat: Hook Secret": {
yaml: true,
},
"Notifications: Email Auth: Password": {
yaml: true,
},
}
set := (&codersdk.DeploymentValues{}).Options()
for _, opt := range set {
// These are generally for development, so their configurability is
// not relevant.
if opt.Hidden {
delete(excludes, opt.Name)
continue
}
if codersdk.IsSecretDeploymentOption(opt) && opt.YAML != "" {
// Secrets should not be written to YAML and instead should continue
// to be read from the environment.
//
// Unfortunately, secrets are still accepted through flags for
// legacy purposes. Eventually, we should prevent that.
t.Errorf("Option %q is a secret but has a YAML name", opt.Name)
}
excluded := excludes[opt.Name]
switch {
case opt.YAML == "" && !excluded.yaml:
t.Errorf("Option %q should have a YAML name", opt.Name)
case opt.YAML != "" && excluded.yaml:
t.Errorf("Option %q is excluded but has a YAML name", opt.Name)
case opt.Flag == "" && !excluded.flag:
t.Errorf("Option %q should have a flag name", opt.Name)
case opt.Flag != "" && excluded.flag:
t.Errorf("Option %q is excluded but has a flag name", opt.Name)
case opt.Env == "" && !excluded.env:
t.Errorf("Option %q should have an env name", opt.Name)
case opt.Env != "" && excluded.env:
t.Errorf("Option %q is excluded but has an env name", opt.Name)
}
// Also check all env vars are prefixed with CODER_
const prefix = "CODER_"
if opt.Env != "" && !strings.HasPrefix(opt.Env, prefix) {
t.Errorf("Option %q has an env name (%q) that is not prefixed with %s", opt.Name, opt.Env, prefix)
}
delete(excludes, opt.Name)
}
for opt := range excludes {
t.Errorf("Excluded option %q is not in the deployment config. Remove it?", opt)
}
}
func TestAIBudgetPeriodAdjective(t *testing.T) {
t.Parallel()
// Every selectable period must have a real adjective.
for _, p := range codersdk.AIBudgetPeriods {
period := codersdk.AIBudgetPeriod(p)
require.NotEqual(t, p, period.Adjective(),
"add an adjective for AI budget period %q in AIBudgetPeriod.Adjective", p)
}
require.Equal(t, "monthly", codersdk.AIBudgetPeriodMonth.Adjective())
}
func TestParseSSHConfigOption(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
option string
wantKey string
wantValue string
wantErr bool
}{
{
name: "ProxyCommandWithSpaces",
option: "ProxyCommand=ssh -W %h:%p bastion",
wantKey: "ProxyCommand",
wantValue: "ssh -W %h:%p bastion",
},
{
name: "SetEnvWithEquals",
option: "SetEnv=FOO=bar BAZ=qux",
wantKey: "SetEnv",
wantValue: "FOO=bar BAZ=qux",
},
{
name: "SetEnvWithSpaceSeparator",
option: "SetEnv FOO=bar BAZ=qux",
wantKey: "SetEnv",
wantValue: "FOO=bar BAZ=qux",
},
{
name: "HostName",
option: "HostName example.com",
wantKey: "HostName",
wantValue: "example.com",
},
{
name: "NewlineInValue",
option: "ProxyCommand=echo hi\nHost *",
wantErr: true,
},
{
name: "CarriageReturnInValue",
option: "ProxyCommand=echo hi\rHost *",
wantErr: true,
},
{
name: "NULInValue",
option: "ProxyCommand=echo hi\x00Host *",
wantErr: true,
},
{
name: "NewlineInKey",
option: "Proxy\nCommand=value",
wantErr: true,
},
{
name: "CarriageReturnInKey",
option: "Proxy\rCommand=value",
wantErr: true,
},
{
name: "NULInKey",
option: "Proxy\x00Command=value",
wantErr: true,
},
{
name: "MissingSeparator",
option: "JustAKeyNoValue",
wantErr: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
key, value, err := codersdk.ParseSSHConfigOption(tt.option)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantKey, key)
require.Equal(t, tt.wantValue, value)
})
}
}
func TestValidateWorkspaceHostnameSuffix(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
suffix string
wantErr bool
}{
{name: "Coder", suffix: "coder"},
{name: "Example", suffix: "example"},
{name: "Dotted", suffix: "coder.example.com"},
{name: "Empty", suffix: ""},
{name: "LeadingDot", suffix: ".coder", wantErr: true},
{name: "Newline", suffix: "coder\nHost *\n\tProxyCommand evil", wantErr: true},
{name: "CarriageReturn", suffix: "coder\r\nHost *", wantErr: true},
{name: "Space", suffix: "coder Host *", wantErr: true},
{name: "Tab", suffix: "coder\t*", wantErr: true},
{name: "NUL", suffix: "coder\x00", wantErr: true},
{name: "NonBreakingSpace", suffix: "coder\u00A0suffix", wantErr: true},
{name: "Glob", suffix: "*", wantErr: true},
{name: "GlobPrefix", suffix: "*.*", wantErr: true},
{name: "QuestionMark", suffix: "code?", wantErr: true},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := codersdk.ValidateWorkspaceHostnameSuffix(tt.suffix)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestValidateWorkspaceHostnamePrefix(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
prefix string
wantErr bool
}{
{name: "Default", prefix: "coder."},
{name: "NoDot", prefix: "coder"},
{name: "Empty", prefix: ""},
{name: "LeadingDot", prefix: ".coder"},
{name: "Newline", prefix: "coder.\nHost *\n\tProxyCommand evil", wantErr: true},
{name: "CarriageReturn", prefix: "coder.\r\nHost *", wantErr: true},
{name: "Space", prefix: "coder. Host *", wantErr: true},
{name: "Tab", prefix: "coder.\t*", wantErr: true},
{name: "NUL", prefix: "coder.\x00", wantErr: true},
{name: "NonBreakingSpace", prefix: "coder.\u00A0x", wantErr: true},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := codersdk.ValidateWorkspaceHostnamePrefix(tt.prefix)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestValidateSSHConfigOptions(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
options map[string]string
wantErr bool
}{
{name: "HostName", options: map[string]string{"HostName": "example.com"}},
{name: "User", options: map[string]string{"User": "coder"}},
{name: "Port", options: map[string]string{"Port": "22"}},
{name: "SetEnv", options: map[string]string{"SetEnv": "FOO=bar BAZ=qux"}},
{name: "UserKnownHostsFile", options: map[string]string{"UserKnownHostsFile": "/tmp/coder_known_hosts"}},
{name: "EmptyKey", options: map[string]string{"": "value"}, wantErr: true},
{name: "NewlineInKey", options: map[string]string{"User\nProxyCommand": "evil"}, wantErr: true},
{name: "CarriageReturnInKey", options: map[string]string{"User\rProxyCommand": "evil"}, wantErr: true},
{name: "NULInKey", options: map[string]string{"User\x00ProxyCommand": "evil"}, wantErr: true},
{name: "SpaceInKey", options: map[string]string{"User ProxyCommand": "evil"}, wantErr: true},
{name: "EqualsInKey", options: map[string]string{"User=ProxyCommand": "evil"}, wantErr: true},
{name: "Host", options: map[string]string{"Host": "*"}, wantErr: true},
{name: "HostCaseInsensitive", options: map[string]string{"hOsT": "*"}, wantErr: true},
{name: "Match", options: map[string]string{"Match": "all"}, wantErr: true},
{name: "Include", options: map[string]string{"Include": "~/.ssh/config.d/*"}, wantErr: true},
{name: "ProxyCommand", options: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"}, wantErr: true},
{name: "ProxyCommandCaseInsensitive", options: map[string]string{"proxycommand": "ssh -W %h:%p bastion"}, wantErr: true},
{name: "LocalCommand", options: map[string]string{"LocalCommand": "echo pwned"}, wantErr: true},
{name: "PermitLocalCommand", options: map[string]string{"PermitLocalCommand": "yes"}, wantErr: true},
{name: "RemoteCommand", options: map[string]string{"RemoteCommand": "some-command"}, wantErr: true},
{name: "KnownHostsCommand", options: map[string]string{"KnownHostsCommand": "echo key"}, wantErr: true},
{name: "PKCS11Provider", options: map[string]string{"PKCS11Provider": "/tmp/evil.so"}, wantErr: true},
{name: "PKCS11ProviderCaseInsensitive", options: map[string]string{"pkcs11provider": "/tmp/evil.so"}, wantErr: true},
{name: "SecurityKeyProvider", options: map[string]string{"SecurityKeyProvider": "/tmp/evil.so"}, wantErr: true},
{name: "NewlineInValue", options: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\nHost *\nProxyCommand evil"}, wantErr: true},
{name: "CarriageReturnInValue", options: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\r\nHost *"}, wantErr: true},
{name: "NULInValue", options: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\x00suffix"}, wantErr: true},
{name: "SmartcardDevice", options: map[string]string{"SmartcardDevice": "/path/to/lib"}, wantErr: true},
{name: "XAuthLocation", options: map[string]string{"XAuthLocation": "/usr/bin/xauth"}, wantErr: true},
{name: "ProxyJump", options: map[string]string{"ProxyJump": "bastion.example.com"}, wantErr: true},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := codersdk.ValidateSSHConfigOptions(tt.options)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestSSHConfigResponse_Validate(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
response codersdk.SSHConfigResponse
wantErr string
}{
{
name: "Valid",
response: codersdk.SSHConfigResponse{
HostnamePrefix: "coder.",
HostnameSuffix: "coder",
SSHConfigOptions: map[string]string{"HostName": "example.com"},
},
},
{
name: "Empty",
response: codersdk.SSHConfigResponse{},
},
{
name: "PrefixUnsafe",
response: codersdk.SSHConfigResponse{HostnamePrefix: "coder.\nHost *"},
wantErr: "workspace hostname prefix",
},
{
name: "SuffixUnsafe",
response: codersdk.SSHConfigResponse{HostnameSuffix: "coder\nHost *"},
wantErr: "workspace hostname suffix",
},
{
name: "OptionUnsafe",
response: codersdk.SSHConfigResponse{SSHConfigOptions: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"}},
wantErr: `ssh config option "ProxyCommand" is not allowed`,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.response.Validate()
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
func TestSSHConfig_ParseOptions(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
ConfigOptions serpent.StringArray
ExpectError bool
Expect map[string]string
}{
{
Name: "Empty",
ConfigOptions: []string{},
Expect: map[string]string{},
},
{
Name: "Whitespace",
ConfigOptions: []string{
"test value",
},
Expect: map[string]string{
"test": "value",
},
},
{
Name: "SimpleValueEqual",
ConfigOptions: []string{
"test=value",
},
Expect: map[string]string{
"test": "value",
},
},
{
Name: "SimpleValues",
ConfigOptions: []string{
"test=value",
"foo=bar",
},
Expect: map[string]string{
"test": "value",
"foo": "bar",
},
},
{
Name: "ValueWithQuote",
ConfigOptions: []string{
"bar=buzz=bazz",
},
Expect: map[string]string{
"bar": "buzz=bazz",
},
},
{
Name: "NoEquals",
ConfigOptions: []string{
"foobar",
},
ExpectError: true,
},
}
for _, tt := range testCases {
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
c := codersdk.SSHConfig{
SSHConfigOptions: tt.ConfigOptions,
}
got, err := c.ParseOptions()
if tt.ExpectError {
require.Error(t, err, tt.ConfigOptions.String())
} else {
require.NoError(t, err, tt.ConfigOptions.String())
require.Equalf(t, tt.Expect, got, tt.ConfigOptions.String())
}
})
}
}
func TestTimezoneOffsets(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Now time.Time
Loc *time.Location
ExpectedOffset int
}{
{
Name: "UTC",
Loc: time.UTC,
ExpectedOffset: 0,
},
{
Name: "Eastern",
Now: time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC),
Loc: must(time.LoadLocation("America/New_York")),
ExpectedOffset: 5,
},
{
// Daylight savings is on the 14th of March to Nov 7 in 2021
Name: "EasternDaylightSavings",
Now: time.Date(2021, 3, 16, 0, 0, 0, 0, time.UTC),
Loc: must(time.LoadLocation("America/New_York")),
ExpectedOffset: 4,
},
{
Name: "Central",
Now: time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC),
Loc: must(time.LoadLocation("America/Chicago")),
ExpectedOffset: 6,
},
{
Name: "CentralDaylightSavings",
Now: time.Date(2021, 3, 16, 0, 0, 0, 0, time.UTC),
Loc: must(time.LoadLocation("America/Chicago")),
ExpectedOffset: 5,
},
{
Name: "Ireland",
Now: time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC),
Loc: must(time.LoadLocation("Europe/Dublin")),
ExpectedOffset: 0,
},
{
Name: "IrelandDaylightSavings",
Now: time.Date(2021, 4, 3, 0, 0, 0, 0, time.UTC),
Loc: must(time.LoadLocation("Europe/Dublin")),
ExpectedOffset: -1,
},
{
Name: "HalfHourTz",
Now: time.Date(2024, 1, 20, 6, 0, 0, 0, must(time.LoadLocation("Asia/Yangon"))),
// This timezone is +6:30, but the function rounds to the nearest hour.
// This is intentional because our DAUs endpoint only covers 1-hour offsets.
// If the user is in a non-hour timezone, they get the closest hour bucket.
Loc: must(time.LoadLocation("Asia/Yangon")),
ExpectedOffset: -6,
},
}
for _, c := range testCases {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
offset := codersdk.TimezoneOffsetHourWithTime(c.Now, c.Loc)
require.Equal(t, c.ExpectedOffset, offset)
})
}
}
func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}
func TestAIGatewayActorHeaderNames(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
args []string
environ serpent.Environ
config string
wantID string
wantUsername string
wantEmail string
}{
{
name: "defaults",
wantID: "X-AI-Bridge-Actor-ID",
wantUsername: "X-AI-Bridge-Actor-Metadata-Username",
wantEmail: "",
},
{
name: "flags",
args: []string{"--ai-gateway-actor-header-id", "X-User-ID", "--ai-gateway-actor-header-username", "X-Username", "--ai-gateway-actor-header-email", "X-Email"},
wantID: "X-User-ID",
wantUsername: "X-Username",
wantEmail: "X-Email",
},
{
name: "environment",
environ: serpent.Environ{
{Name: "CODER_AI_GATEWAY_ACTOR_HEADER_ID", Value: "X-User-ID"},
{Name: "CODER_AI_GATEWAY_ACTOR_HEADER_USERNAME", Value: "X-Username"},
{Name: "CODER_AI_GATEWAY_ACTOR_HEADER_EMAIL", Value: "X-Email"},
},
wantID: "X-User-ID",
wantUsername: "X-Username",
wantEmail: "X-Email",
},
{
name: "YAML",
config: "ai_gateway:\n actor_header_id: X-User-ID\n actor_header_username: X-Username\n actor_header_email: X-Email\n",
wantID: "X-User-ID",
wantUsername: "X-Username",
wantEmail: "X-Email",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dv := &codersdk.DeploymentValues{}
called := false
cmd := &serpent.Command{
Use: "test",
Options: dv.Options(),
Handler: func(_ *serpent.Invocation) error {
called = true
require.Equal(t, tc.wantID, dv.AI.BridgeConfig.ActorHeaderID.Value())
require.Equal(t, tc.wantUsername, dv.AI.BridgeConfig.ActorHeaderUsername.Value())
require.Equal(t, tc.wantEmail, dv.AI.BridgeConfig.ActorHeaderEmail.Value())
return dv.Validate()
},
}
inv := cmd.Invoke(tc.args...)
inv.Environ = append(serpent.Environ{}, tc.environ...)
if tc.config != "" {
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(tc.config), 0o600))
inv.Environ = append(inv.Environ, serpent.EnvVar{Name: "CODER_CONFIG_PATH", Value: path})
}
require.NoError(t, inv.Run())
require.True(t, called, "configuration must reach validation")
})
}
t.Run("JSON", func(t *testing.T) {
t.Parallel()
cfg := codersdk.AIBridgeConfig{
ActorHeaderID: serpent.String("X-User-ID"),
ActorHeaderUsername: serpent.String("X-Username"),
ActorHeaderEmail: serpent.String("X-Email"),
}
encoded, err := json.Marshal(cfg)
require.NoError(t, err)
var fields map[string]json.RawMessage
require.NoError(t, json.Unmarshal(encoded, &fields))
require.JSONEq(t, `"X-User-ID"`, string(fields["actor_header_id"]))
require.JSONEq(t, `"X-Username"`, string(fields["actor_header_username"]))
require.JSONEq(t, `"X-Email"`, string(fields["actor_header_email"]))
require.NotContains(t, fields, "actor_header_names")
require.NotContains(t, fields, "actor_header_meta_username")
})
t.Run("validation", func(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
id string
username string
email string
wantErr string
}{
{name: "both empty"},
{name: "ID only", id: "X-User-ID"},
{name: "username only", username: "X-Username"},
{name: "email only", email: "X-Email"},
{name: "custom names", id: "X-User-ID", username: "X-Username", email: "X-Email"},
{name: "invalid", username: "Bad: Header", wantErr: `invalid AI Gateway actor header name "Bad: Header" for CODER_AI_GATEWAY_ACTOR_HEADER_USERNAME`},
{name: "email standard exception", email: "x-ai-bridge-actor-metadata-email"},
{name: "email collision", username: "X-User", email: "x-user", wantErr: `duplicate AI Gateway actor header name "x-user" for CODER_AI_GATEWAY_ACTOR_HEADER_USERNAME and CODER_AI_GATEWAY_ACTOR_HEADER_EMAIL`},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := codersdk.AIBridgeConfig{
ActorHeaderID: serpent.String(tc.id),
ActorHeaderUsername: serpent.String(tc.username),
ActorHeaderEmail: serpent.String(tc.email),
}
err := cfg.ValidateActorHeaderNames()
if tc.wantErr != "" {
require.EqualError(t, err, tc.wantErr)
} else {
require.NoError(t, err)
}
})
}
})
t.Run("actor prefix", func(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
id string
username string
email string
reserved string
}{
{name: "own standard names", id: "x-aI-bRiDgE-aCtOr-iD", username: "x-aI-bRiDgE-aCtOr-mEtAdAtA-uSeRnAmE"},
{name: "ID uses username header", id: "x-ai-bridge-actor-metadata-username", reserved: "x-ai-bridge-actor-metadata-username"},
{name: "username uses ID header", username: "x-ai-bridge-actor-id", reserved: "x-ai-bridge-actor-id"},
{name: "mixed-case metadata prefix", id: "x-aI-bRiDgE-aCtOr-mEtAdAtA-oThEr", reserved: "x-aI-bRiDgE-aCtOr-mEtAdAtA-oThEr"},
{name: "exact prefix", username: "x-aI-bRiDgE-aCtOr", reserved: "x-aI-bRiDgE-aCtOr"},
{name: "prefix without separator", username: "x-aI-bRiDgE-aCtOrOther", reserved: "x-aI-bRiDgE-aCtOrOther"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := codersdk.AIBridgeConfig{
ActorHeaderID: serpent.String(tc.id),
ActorHeaderUsername: serpent.String(tc.username),
ActorHeaderEmail: serpent.String(tc.email),
}
err := cfg.ValidateActorHeaderNames()
if tc.reserved != "" {
require.EqualError(t, err, fmt.Sprintf("reserved AI Gateway actor header name %q", tc.reserved))
} else {
require.NoError(t, err)
}
})
}
})
t.Run("reserved names", func(t *testing.T) {
t.Parallel()
for _, name := range []string{
"Authorization", "X-Api-Key", "Proxy-Authorization", "Proxy-Authenticate",
"Cookie", "Set-Cookie", "Host", "User-Agent", "Content-Length", "Content-Type", "Content-Encoding", "Accept-Encoding",
"Connection", "Keep-Alive", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Forwarded-Port",
"Coder-Session-Token", "X-Coder-Ai-Governance-Token", "X-Coder-Ai-Governance-Request-Id",
"X-Coder-Agent-Firewall-Session-Id", "X-Coder-Agent-Firewall-Sequence-Number",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
cfg := codersdk.AIBridgeConfig{ActorHeaderID: serpent.String(name)}
require.EqualError(t, cfg.ValidateActorHeaderNames(), fmt.Sprintf("reserved AI Gateway actor header name %q", name))
lower := strings.ToLower(name)
cfg = codersdk.AIBridgeConfig{ActorHeaderUsername: serpent.String(lower)}
require.EqualError(t, cfg.ValidateActorHeaderNames(), fmt.Sprintf("reserved AI Gateway actor header name %q", lower))
})
}
})
}
func TestAIGatewayCompatibilityAliases(t *testing.T) {
t.Parallel()
options := (&codersdk.DeploymentValues{}).Options()
byFlag := map[string]serpent.Option{}
for _, opt := range options {
if opt.Flag != "" {
byFlag[opt.Flag] = opt
}
}
type alias struct {
old serpent.Option
new serpent.Option
}
var aliases []alias
for _, opt := range options {
if !strings.HasPrefix(opt.Flag, "aibridge-") {
continue
}
require.True(t, strings.HasPrefix(opt.Description, "Deprecated:"), "aibridge option %s should have a 'Deprecated:' description", opt.Flag)
require.Len(t, opt.UseInstead, 1, "aibridge option %s should point to a single replacement", opt.Flag)
newOpt, ok := byFlag[opt.UseInstead[0].Flag]
require.True(t, ok, "aibridge option %s points to unknown flag %s", opt.Flag, opt.UseInstead[0].Flag)
require.NotEqual(t, opt.Flag, newOpt.Flag, "flag %s shares its flag with the new alias option", opt.Flag)
require.NotEqual(t, opt.Env, newOpt.Env, "flag %s shares its env with the new alias option", opt.Flag)
if oldYAML := opt.YAMLPath(); oldYAML != "" {
require.NotEqual(t, oldYAML, newOpt.YAMLPath(), "flag %s shares its YAML path with the new alias option", opt.Flag)
} else {
require.Empty(t, newOpt.YAMLPath(), "flag %s has no YAML path but the new alias option %s does", opt.Flag, newOpt.Flag)
}
aliases = append(aliases, alias{old: opt, new: newOpt})
}
// Update this count when adding or removing aibridge alias options.
require.Len(t, aliases, 24, "unexpected number of aibridge alias options")
sampleVal := func(opt serpent.Option) any {
switch opt.Value.Type() {
case "bool":
return opt.Default != "true"
case "int":
return 7
case "duration":
return "2h"
case "string-array":
return []string{"10.0.0.0/8", "172.16.0.0/12"}
default:
return "alias-value"
}
}
sampleArg := func(opt serpent.Option) string {
v := sampleVal(opt)
if arr, ok := v.([]string); ok {
return strings.Join(arr, ",")
}
return fmt.Sprint(v)
}
aiConfFromOpts := func(t *testing.T, apply func(opts serpent.OptionSet) error) codersdk.AIConfig {
t.Helper()
dv := &codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
require.NoError(t, apply(opts))
return dv.AI
}
t.Run("FlagParity", func(t *testing.T) {
t.Parallel()
var oldArgs, newArgs []string
for _, a := range aliases {
value := sampleArg(a.old)
oldArgs = append(oldArgs, "--"+a.old.Flag, value)
newArgs = append(newArgs, "--"+a.new.Flag, value)
}
oldAI := aiConfFromOpts(t, func(opts serpent.OptionSet) error {
return opts.FlagSet().Parse(oldArgs)
})
newAI := aiConfFromOpts(t, func(opts serpent.OptionSet) error {
return opts.FlagSet().Parse(newArgs)
})
require.Equal(t, newAI, oldAI)
})
t.Run("EnvParity", func(t *testing.T) {
t.Parallel()
var oldEnv, newEnv []serpent.EnvVar
for _, a := range aliases {
value := sampleArg(a.old)
oldEnv = append(oldEnv, serpent.EnvVar{Name: a.old.Env, Value: value})
newEnv = append(newEnv, serpent.EnvVar{Name: a.new.Env, Value: value})
}
oldAI := aiConfFromOpts(t, func(opts serpent.OptionSet) error {
return opts.ParseEnv(oldEnv)
})
newAI := aiConfFromOpts(t, func(opts serpent.OptionSet) error {
return opts.ParseEnv(newEnv)
})
require.Equal(t, newAI, oldAI)
})
t.Run("YAMLParity", func(t *testing.T) {
t.Parallel()
setPath := func(doc map[string]any, path string, value any) {
parts := strings.Split(path, ".")
for _, field := range parts[:len(parts)-1] {
next, ok := doc[field].(map[string]any)
if !ok {
next = map[string]any{}
doc[field] = next
}
doc = next
}
doc[parts[len(parts)-1]] = value
}
oldYAML := map[string]any{}
newYAML := map[string]any{}
for _, a := range aliases {
oldPath := a.old.YAMLPath()
newPath := a.new.YAMLPath()
if oldPath == "" {
require.Empty(t, newPath)
continue
}
require.NotEmpty(t, newPath, "new flag %s has no YAML path", a.old.Flag)
value := sampleVal(a.old)
setPath(oldYAML, oldPath, value)
setPath(newYAML, newPath, value)
}
parse := func(doc map[string]any) codersdk.AIConfig {
var node yaml.Node
require.NoError(t, node.Encode(doc))
return aiConfFromOpts(t, func(opts serpent.OptionSet) error {
return opts.UnmarshalYAML(&node)
})
}
require.Equal(t, parse(newYAML), parse(oldYAML))
})
}
// defaultDeploymentValues returns deployment values with every option set to
// its default, which passes Validate.
func defaultDeploymentValues(t *testing.T) *codersdk.DeploymentValues {
t.Helper()
dv := &codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
return dv
}
func TestDeploymentValues_Validate_RefreshLifetime(t *testing.T) {
t.Parallel()
mk := func(t *testing.T, access, refresh time.Duration) *codersdk.DeploymentValues {
dv := defaultDeploymentValues(t)
dv.Sessions.DefaultDuration = serpent.Duration(access)
dv.Sessions.RefreshDefaultDuration = serpent.Duration(refresh)
return dv
}
t.Run("EqualDurations_Error", func(t *testing.T) {
t.Parallel()
dv := mk(t, 1*time.Hour, 1*time.Hour)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "must be strictly greater")
})
t.Run("RefreshShorter_Error", func(t *testing.T) {
t.Parallel()
dv := mk(t, 2*time.Hour, 1*time.Hour)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "must be strictly greater")
})
t.Run("RefreshZero_Error", func(t *testing.T) {
t.Parallel()
dv := mk(t, 1*time.Hour, 0)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "must be strictly greater")
})
t.Run("AccessUninitialized_Error", func(t *testing.T) {
t.Parallel()
// Access duration is zero (uninitialized); refresh is valid.
dv := mk(t, 0, 48*time.Hour)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "developer error: sessions configuration appears uninitialized")
})
t.Run("RefreshLonger_OK", func(t *testing.T) {
t.Parallel()
dv := mk(t, 1*time.Hour, 48*time.Hour)
err := dv.Validate()
require.NoError(t, err)
})
}
func TestDeploymentValues_Validate_ChatLimits(t *testing.T) {
t.Parallel()
limits := []struct {
flag string
value func(*codersdk.DeploymentValues) *serpent.Int64
}{
{"chat-max-steps-per-turn", func(dv *codersdk.DeploymentValues) *serpent.Int64 { return &dv.AI.Chat.MaxStepsPerTurn }},
{"chat-max-generation-retries", func(dv *codersdk.DeploymentValues) *serpent.Int64 { return &dv.AI.Chat.MaxGenerationRetries }},
{"chat-max-queued-messages-per-chat", func(dv *codersdk.DeploymentValues) *serpent.Int64 { return &dv.AI.Chat.MaxQueuedMessagesPerChat }},
{"chat-max-attachments-per-chat", func(dv *codersdk.DeploymentValues) *serpent.Int64 { return &dv.AI.Chat.MaxAttachmentsPerChat }},
{"chat-max-prompt-bytes", func(dv *codersdk.DeploymentValues) *serpent.Int64 { return &dv.AI.Chat.MaxPromptBytes }},
{"chat-max-concurrent-recording-uploads", func(dv *codersdk.DeploymentValues) *serpent.Int64 { return &dv.AI.Chat.MaxConcurrentRecordingUploads }},
}
values := []struct {
value int64
valid bool
}{
{value: -1},
{value: 0},
{value: 1, valid: true},
{value: math.MaxInt32, valid: true},
{value: math.MaxInt32 + 1},
}
for _, limit := range limits {
for _, tc := range values {
t.Run(fmt.Sprintf("%s=%d", limit.flag, tc.value), func(t *testing.T) {
t.Parallel()
dv := defaultDeploymentValues(t)
*limit.value(dv) = serpent.Int64(tc.value)
err := dv.Validate()
if tc.valid {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, fmt.Sprintf("--%s (%d) must be between 1 and", limit.flag, tc.value))
})
}
}
}
func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
t.Parallel()
tests := []struct {
name string
disabled bool
url string
secret string
timeout time.Duration
allowInsecure bool
wantErr string
}{
{
name: "NoURL",
timeout: 1500 * time.Millisecond,
},
{
name: "DisabledSkipsValidation",
disabled: true,
url: "http://hooks.example.com/agent",
timeout: 0,
},
{
name: "Valid",
url: "https://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 5 * time.Second,
},
{
name: "HTTPURL",
url: "http://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "chat hook URL must use HTTPS",
},
{
name: "HTTPURLAllowInsecure",
url: "http://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
},
{
name: "NonHTTPSchemeAllowInsecure",
url: "ftp://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "chat hook URL must use HTTPS",
},
{
name: "AllowInsecureStillRequiresSecret",
url: "http://hooks.example.com/agent",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "chat hook secret is required",
},
{
name: "HostlessURL",
url: "https:///hook",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "must include a host",
},
{
name: "HostlessHTTPURLAllowInsecure",
url: "http:///hook",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "set --chat-hook-url to a complete URL",
},
{
name: "PortOnlyHTTPURLAllowInsecure",
url: "http://:8080/hooks",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "must include a host",
},
{
name: "PortOnlyHTTPSURL",
url: "https://:8080/hooks",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "must include a host",
},
{
name: "FragmentURL",
url: "https://hooks.example.com/agent#frag",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "must not contain a fragment or userinfo",
},
{
name: "FragmentHTTPURLAllowInsecure",
url: "http://hooks.example.com/agent#frag",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "set --chat-hook-url to a URL without a fragment or userinfo",
},
{
name: "UserinfoURL",
url: "https://user:pass@hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "must not contain a fragment or userinfo",
},
{
name: "MissingSecret",
url: "https://hooks.example.com/agent",
timeout: 1500 * time.Millisecond,
wantErr: "chat hook secret is required",
},
{
name: "ShortSecret",
url: "https://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcde",
timeout: 1500 * time.Millisecond,
wantErr: "chat hook secret must be at least 32 bytes",
},
{
name: "ZeroTimeout",
url: "https://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 0,
wantErr: "chat hook timeout",
},
{
name: "NegativeTimeout",
url: "https://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: -time.Millisecond,
wantErr: "chat hook timeout",
},
{
name: "TimeoutAboveMaximum",
url: "https://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 5*time.Second + time.Millisecond,
wantErr: "chat hook timeout",
},
{
name: "NoURLSkipsTimeoutValidation",
timeout: 10 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dv := defaultDeploymentValues(t)
dv.AI.Chat.HookEnabled = serpent.Bool(!tt.disabled)
dv.AI.Chat.HookSecret = serpent.String(tt.secret)
dv.AI.Chat.HookTimeout = serpent.Duration(tt.timeout)
dv.AI.Chat.HookAllowInsecure = serpent.Bool(tt.allowInsecure)
if tt.url != "" {
require.NoError(t, dv.AI.Chat.HookURL.Set(tt.url))
}
err := dv.Validate()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func TestDeploymentValues_Validate_ChatStreamSilenceTimeout(t *testing.T) {
t.Parallel()
tests := []struct {
name string
timeout time.Duration
wantErr string
}{
{name: "Disabled", timeout: 0},
{name: "Negative", timeout: -time.Second, wantErr: "chat stream silence timeout"},
{name: "Maximum", timeout: 24 * time.Hour},
{name: "AboveMaximum", timeout: 24*time.Hour + time.Second, wantErr: "chat stream silence timeout"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dv := defaultDeploymentValues(t)
dv.AI.Chat.StreamSilenceTimeout = serpent.Duration(tt.timeout)
err := dv.Validate()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func TestDeploymentValues_DurationFormatNanoseconds(t *testing.T) {
t.Parallel()
set := (&codersdk.DeploymentValues{}).Options()
for _, s := range set {
if s.Value.Type() != "duration" {
continue
}
// Just make sure the annotation is set.
// If someone wants to not format a duration, they can
// explicitly set the annotation to false.
if s.Annotations.IsSet("format_duration") {
continue
}
t.Logf("Option %q is a duration but does not have the format_duration annotation.", s.Name)
t.Log("To fix this, add the following to the option declaration:")
t.Log(`Annotations: serpent.Annotations{}.Mark(annotationFormatDurationNS, "true"),`)
t.FailNow()
}
}
//go:embed testdata/*
var testData embed.FS
func TestExternalAuthYAMLConfig(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
// The windows marshal function uses different line endings.
// Not worth the effort getting this to work on windows.
t.SkipNow()
}
file := func(t *testing.T, name string) string {
data, err := testData.ReadFile(fmt.Sprintf("testdata/%s", name))
require.NoError(t, err, "read testdata file %q", name)
return string(data)
}
githubCfg := codersdk.ExternalAuthConfig{
Type: "github",
ClientID: "client_id",
ClientSecret: "client_secret",
ID: "id",
AuthURL: "https://example.com/auth",
TokenURL: "https://example.com/token",
RedirectURL: "https://example.com/redirect",
ValidateURL: "https://example.com/validate",
RevokeURL: "https://example.com/revoke",
AppInstallURL: "https://example.com/install",
AppInstallationsURL: "https://example.com/installations",
NoRefresh: true,
Scopes: []string{"user:email", "read:org"},
ExtraTokenKeys: []string{"extra", "token"},
DeviceFlow: true,
DeviceCodeURL: "https://example.com/device",
Regex: "^https://example.com/.*$",
DisplayName: "GitHub",
DisplayIcon: "/static/icons/github.svg",
MCPURL: "https://api.githubcopilot.com/mcp/",
MCPToolAllowRegex: ".*",
MCPToolDenyRegex: "create_gist",
CodeChallengeMethodsSupported: []string{"S256"},
}
// Input the github section twice for testing a slice of configs.
inputYAML := func() string {
f := file(t, "githubcfg.yaml")
lines := strings.SplitN(f, "\n", 2)
// Append github config twice
return f + lines[1]
}()
expected := []codersdk.ExternalAuthConfig{
githubCfg, githubCfg,
}
dv := codersdk.DeploymentValues{}
opts := dv.Options()
// replace any tabs with the proper space indentation
inputYAML = strings.ReplaceAll(inputYAML, "\t", " ")
// This is the order things are done in the cli, so just
// keep it the same.
var n yaml.Node
err := yaml.Unmarshal([]byte(inputYAML), &n)
require.NoError(t, err)
err = n.Decode(&opts)
require.NoError(t, err)
require.ElementsMatchf(t, expected, dv.ExternalAuthConfigs.Value, "from yaml")
var out bytes.Buffer
enc := yaml.NewEncoder(&out)
enc.SetIndent(2)
err = enc.Encode(dv.ExternalAuthConfigs)
require.NoError(t, err)
// Because we only marshal the 1 section, the correct section name is not applied.
output := strings.Replace(out.String(), "value:", "externalAuthProviders:", 1)
require.Equal(t, inputYAML, output, "re-marshaled is the same as input")
}
func TestFeatureComparison(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
A codersdk.Feature
B codersdk.Feature
Expected int
}{
{
Name: "Empty",
Expected: 0,
},
// Entitlement check
// Entitled
{
Name: "EntitledVsGracePeriod",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod},
Expected: 1,
},
{
Name: "EntitledVsGracePeriodLimits",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
// Entitled should still win here
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: new(int64(100)), Actual: new(int64(50))},
Expected: 1,
},
{
Name: "EntitledVsNotEntitled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
B: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled},
Expected: 3,
},
{
Name: "EntitledVsUnknown",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled},
B: codersdk.Feature{Entitlement: ""},
Expected: 4,
},
// GracePeriod
{
Name: "GracefulVsNotEntitled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod},
B: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled},
Expected: 2,
},
{
Name: "GracefulVsUnknown",
A: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod},
B: codersdk.Feature{Entitlement: ""},
Expected: 3,
},
// NotEntitled
{
Name: "NotEntitledVsUnknown",
A: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled},
B: codersdk.Feature{Entitlement: ""},
Expected: 1,
},
// --
{
Name: "EntitledVsGracePeriodCapable",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: new(int64(300)), Actual: new(int64(200))},
Expected: -1,
},
// UserLimits
{
// Tests an exceeded limit that is entitled vs a graceful limit that
// is not exceeded. This is the edge case that we should use the graceful period
// instead of the entitled.
Name: "UserLimitExceeded",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: new(int64(300)), Actual: new(int64(200))},
Expected: -1,
},
{
Name: "UserLimitExceededNoEntitled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementNotEntitled, Limit: new(int64(300)), Actual: new(int64(200))},
Expected: 3,
},
{
Name: "HigherLimit",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(110)), Actual: new(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(200))},
Expected: 10, // Diff in the limit #
},
{
Name: "HigherActual",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(300))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(200))},
Expected: 100, // Diff in the actual #
},
{
Name: "LimitExists",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: new(int64(200))},
Expected: 1,
},
{
Name: "LimitExistsGrace",
A: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: new(int64(100)), Actual: new(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementGracePeriod, Limit: nil, Actual: new(int64(200))},
Expected: 1,
},
{
Name: "ActualExists",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: nil},
Expected: 1,
},
{
Name: "NotNils",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil},
Expected: 1,
},
{
Name: "EnabledVsDisabled",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Enabled: true, Limit: new(int64(300)), Actual: new(int64(200))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(300)), Actual: new(int64(200))},
Expected: 1,
},
{
Name: "NotNils",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), Actual: new(int64(50))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil},
Expected: 1,
},
{
Name: "SoftHardLimitsIgnored",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100)), SoftLimit: new(int64(80)), HardLimit: new(int64(120))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: new(int64(100))},
Expected: 0,
},
{
Name: "NewerIssuedAtWinsOverSoftHardLimits",
A: codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Limit: new(int64(50)),
SoftLimit: new(int64(40)),
HardLimit: new(int64(60)),
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
Start: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
},
},
B: codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Limit: new(int64(100)),
SoftLimit: new(int64(80)),
HardLimit: new(int64(120)),
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
},
},
Expected: 1,
},
{
// A nil limit on a usage period feature means unlimited, so it
// outranks a set limit on an exact usage period tie.
Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
A: codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Enabled: true,
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
},
},
B: codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Enabled: true,
Limit: new(int64(100)),
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
},
},
Expected: 1,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
r := tc.A.Compare(tc.B)
logIt := !assert.Equal(t, tc.Expected, r)
// Comparisons should be like addition. A - B = -1 * (B - A)
r = tc.B.Compare(tc.A)
logIt = logIt || !assert.Equalf(t, tc.Expected*-1, r, "the inverse comparison should also be true")
if logIt {
ad, _ := json.Marshal(tc.A)
bd, _ := json.Marshal(tc.B)
t.Logf("a = %s\nb = %s", ad, bd)
}
})
}
}
// TestPremiumSuperSet tests that the "premium" feature set is a superset of the
// "enterprise" feature set.
func TestPremiumSuperSet(t *testing.T) {
t.Parallel()
enterprise := codersdk.FeatureSetEnterprise
premium := codersdk.FeatureSetPremium
// Premium > Enterprise
require.Greater(t, len(premium.Features()), len(enterprise.Features()), "premium should have more features than enterprise")
// Premium ⊃ Enterprise
require.Subset(t, premium.Features(), enterprise.Features(), "premium should be a superset of enterprise. If this fails, update the premium feature set to include all enterprise features.")
// Premium = All Features EXCEPT limit-based features.
// TODO: In future release, also exclude addon features (f.IsAddonFeature()).
expectedPremiumFeatures := []codersdk.FeatureName{}
for _, feature := range codersdk.FeatureNames {
if feature.UsesLimit() {
continue
}
expectedPremiumFeatures = append(expectedPremiumFeatures, feature)
}
require.NotEmpty(t, expectedPremiumFeatures, "expectedPremiumFeatures should not be empty")
require.ElementsMatch(t, premium.Features(), expectedPremiumFeatures, "premium should contain all features except usage limit features")
// This check exists because if you misuse the slices.Delete, you can end up
// with zero'd values.
require.NotContains(t, enterprise.Features(), "", "enterprise should not contain empty string")
require.NotContains(t, premium.Features(), "", "premium should not contain empty string")
}
func TestNotificationsCanBeDisabled(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expectNotificationsEnabled bool
environment []serpent.EnvVar
}{
{
name: "NoDeliveryMethodSet",
environment: []serpent.EnvVar{},
expectNotificationsEnabled: false,
},
{
name: "SMTP_DeliveryMethodSet",
environment: []serpent.EnvVar{
{
Name: "CODER_EMAIL_SMARTHOST",
Value: "localhost:587",
},
},
expectNotificationsEnabled: true,
},
{
name: "Webhook_DeliveryMethodSet",
environment: []serpent.EnvVar{
{
Name: "CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT",
Value: "https://example.com/webhook",
},
},
expectNotificationsEnabled: true,
},
{
name: "WebhookAndSMTP_DeliveryMethodSet",
environment: []serpent.EnvVar{
{
Name: "CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT",
Value: "https://example.com/webhook",
},
{
Name: "CODER_EMAIL_SMARTHOST",
Value: "localhost:587",
},
},
expectNotificationsEnabled: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
err := opts.ParseEnv(tt.environment)
require.NoError(t, err)
require.Equal(t, tt.expectNotificationsEnabled, dv.Notifications.Enabled())
})
}
}
func TestRetentionConfigParsing(t *testing.T) {
t.Parallel()
tests := []struct {
name string
environment []serpent.EnvVar
expectedAuditLogs time.Duration
expectedConnectionLogs time.Duration
expectedAPIKeys time.Duration
}{
{
name: "Defaults",
environment: []serpent.EnvVar{},
expectedAuditLogs: 0,
expectedConnectionLogs: 0,
expectedAPIKeys: 7 * 24 * time.Hour, // 7 days default
},
{
name: "IndividualRetentionSet",
environment: []serpent.EnvVar{
{Name: "CODER_AUDIT_LOGS_RETENTION", Value: "30d"},
{Name: "CODER_CONNECTION_LOGS_RETENTION", Value: "60d"},
{Name: "CODER_API_KEYS_RETENTION", Value: "14d"},
},
expectedAuditLogs: 30 * 24 * time.Hour,
expectedConnectionLogs: 60 * 24 * time.Hour,
expectedAPIKeys: 14 * 24 * time.Hour,
},
{
name: "AllRetentionSet",
environment: []serpent.EnvVar{
{Name: "CODER_AUDIT_LOGS_RETENTION", Value: "365d"},
{Name: "CODER_CONNECTION_LOGS_RETENTION", Value: "30d"},
{Name: "CODER_API_KEYS_RETENTION", Value: "0"},
},
expectedAuditLogs: 365 * 24 * time.Hour,
expectedConnectionLogs: 30 * 24 * time.Hour,
expectedAPIKeys: 0, // Explicitly disabled
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
err := opts.SetDefaults()
require.NoError(t, err)
err = opts.ParseEnv(tt.environment)
require.NoError(t, err)
assert.Equal(t, tt.expectedAuditLogs, dv.Retention.AuditLogs.Value(), "audit logs retention mismatch")
assert.Equal(t, tt.expectedConnectionLogs, dv.Retention.ConnectionLogs.Value(), "connection logs retention mismatch")
assert.Equal(t, tt.expectedAPIKeys, dv.Retention.APIKeys.Value(), "api keys retention mismatch")
})
}
}
func TestDisableUserSecretFilePath(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
require.False(t, dv.DisableUserSecretFilePath.Value(), "must default to false")
var opt serpent.Option
for _, o := range opts {
if o.Value == &dv.DisableUserSecretFilePath {
opt = o
break
}
}
require.NotEmpty(t, opt.Flag, "option must be registered")
assert.Equal(t, "disable-user-secret-file-path", opt.Flag)
assert.Equal(t, "CODER_DISABLE_USER_SECRET_FILE_PATH", opt.Env)
assert.Equal(t, "disableUserSecretFilePath", opt.YAML)
require.NoError(t, opts.ParseEnv([]serpent.EnvVar{
{Name: "CODER_DISABLE_USER_SECRET_FILE_PATH", Value: "true"},
}))
require.True(t, dv.DisableUserSecretFilePath.Value(), "env must set the value")
yamlDV := codersdk.DeploymentValues{}
yamlOpts := yamlDV.Options()
var node yaml.Node
require.NoError(t, yaml.Unmarshal([]byte("disableUserSecretFilePath: true\n"), &node))
require.NoError(t, node.Decode(&yamlOpts))
require.True(t, yamlDV.DisableUserSecretFilePath.Value(), "yaml must set the value")
// The option is not a secret, so telemetry and the config endpoint
// must keep reporting it after sanitization.
full := codersdk.DeploymentValues{DisableUserSecretFilePath: true}
sanitized, err := full.WithoutSecrets()
require.NoError(t, err)
require.True(t, sanitized.DisableUserSecretFilePath.Value())
}
func TestChatAIGatewayRoutingEnabledDefault(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
require.True(t, dv.AI.Chat.AIGatewayRoutingEnabled.Value())
}
func TestAIBudgetConfigParsing(t *testing.T) {
t.Parallel()
t.Run("Defaults", func(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
assert.Equal(t, string(codersdk.AIBudgetPolicyHighest), dv.AI.BridgeConfig.BudgetPolicy)
assert.Equal(t, string(codersdk.AIBudgetPeriodMonth), dv.AI.BridgeConfig.BudgetPeriod)
})
t.Run("AcceptsSupportedValues", func(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
require.NoError(t, opts.ParseEnv([]serpent.EnvVar{
{Name: "CODER_AI_BUDGET_POLICY", Value: string(codersdk.AIBudgetPolicyHighest)},
{Name: "CODER_AI_BUDGET_PERIOD", Value: string(codersdk.AIBudgetPeriodMonth)},
}))
assert.Equal(t, string(codersdk.AIBudgetPolicyHighest), dv.AI.BridgeConfig.BudgetPolicy)
assert.Equal(t, string(codersdk.AIBudgetPeriodMonth), dv.AI.BridgeConfig.BudgetPeriod)
})
t.Run("RejectsUnsupportedPolicy", func(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
err := opts.ParseEnv([]serpent.EnvVar{
{Name: "CODER_AI_BUDGET_POLICY", Value: "invalid"},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid choice")
})
t.Run("RejectsUnsupportedPeriod", func(t *testing.T) {
t.Parallel()
dv := codersdk.DeploymentValues{}
opts := dv.Options()
require.NoError(t, opts.SetDefaults())
err := opts.ParseEnv([]serpent.EnvVar{
{Name: "CODER_AI_BUDGET_PERIOD", Value: "invalid"},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid choice")
})
}
func TestNewAIBudgetPolicyFromString(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want codersdk.AIBudgetPolicy
}{
{name: "supported", in: "highest", want: codersdk.AIBudgetPolicyHighest},
{name: "empty falls back to highest", in: "", want: codersdk.AIBudgetPolicyHighest},
{name: "unknown falls back to highest", in: "unsupported", want: codersdk.AIBudgetPolicyHighest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, codersdk.NewAIBudgetPolicyFromString(tt.in))
})
}
}
func TestComputeMaxIdleConns(t *testing.T) {
t.Parallel()
tests := []struct {
name string
maxOpen int
configuredIdle string
expectedIdle int
expectError bool
errorContains string
}{
{
name: "auto_default_10_open",
maxOpen: 10,
configuredIdle: "auto",
expectedIdle: 3, // 10/3 = 3
},
{
name: "auto_with_whitespace",
maxOpen: 10,
configuredIdle: " auto ",
expectedIdle: 3, // 10/3 = 3
},
{
name: "auto_30_open",
maxOpen: 30,
configuredIdle: "auto",
expectedIdle: 10, // 30/3 = 10
},
{
name: "auto_minimum_1",
maxOpen: 1,
configuredIdle: "auto",
expectedIdle: 1, // 1/3 = 0, but minimum is 1
},
{
name: "auto_minimum_2_open",
maxOpen: 2,
configuredIdle: "auto",
expectedIdle: 1, // 2/3 = 0, but minimum is 1
},
{
name: "auto_3_open",
maxOpen: 3,
configuredIdle: "auto",
expectedIdle: 1, // 3/3 = 1
},
{
name: "explicit_equal_to_max",
maxOpen: 10,
configuredIdle: "10",
expectedIdle: 10,
},
{
name: "explicit_less_than_max",
maxOpen: 10,
configuredIdle: "5",
expectedIdle: 5,
},
{
name: "explicit_with_whitespace",
maxOpen: 10,
configuredIdle: " 5 ",
expectedIdle: 5,
},
{
name: "explicit_0",
maxOpen: 10,
configuredIdle: "0",
expectedIdle: 0,
},
{
name: "error_exceeds_max",
maxOpen: 10,
configuredIdle: "15",
expectError: true,
errorContains: "cannot exceed",
},
{
name: "error_exceeds_max_by_1",
maxOpen: 10,
configuredIdle: "11",
expectError: true,
errorContains: "cannot exceed",
},
{
name: "error_invalid_string",
maxOpen: 10,
configuredIdle: "invalid",
expectError: true,
errorContains: "must be \"auto\" or >= 0",
},
{
name: "error_negative",
maxOpen: 10,
configuredIdle: "-1",
expectError: true,
errorContains: "must be \"auto\" or >= 0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result, err := codersdk.ComputeMaxIdleConns(tt.maxOpen, tt.configuredIdle)
if tt.expectError {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errorContains)
} else {
require.NoError(t, err)
require.Equal(t, tt.expectedIdle, result)
}
})
}
}
func TestHTTPCookieConfigMiddleware(t *testing.T) {
t.Parallel()
// Realistic cookies that are always present in production.
// These cookies are added to every test.
baseCookies := []*http.Cookie{
{Name: "_ga", Value: "GA1.1.661026807.1770083336"},
{Name: "_ga_G0Q1B9GRC0", Value: "GS2.1.s1771343727$o49$g1$t1771343993$j48$l0$h0"},
{Name: "csrf_token", Value: "gDiKk8GjTM2iCUHAPfN9GlC+DGjzAprlLi2vJ+5TBU0="},
}
cases := []struct {
name string
cfg codersdk.HTTPCookieConfig
extraCookies []*http.Cookie
expectedCookies map[string]string // cookie name -> value that handler should see
expectedDeleted []string // if any cookies are supposed to be deleted via Set-Cookie
}{
{
name: "Disabled_PassesThrough",
cfg: codersdk.HTTPCookieConfig{},
extraCookies: []*http.Cookie{
{Name: codersdk.SessionTokenCookie, Value: "token123"},
},
expectedCookies: map[string]string{
codersdk.SessionTokenCookie: "token123",
},
},
{
name: "Enabled_StripsPrefixFromCookie",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
{Name: "__Host-" + codersdk.SessionTokenCookie, Value: "token123"},
},
expectedCookies: map[string]string{
codersdk.SessionTokenCookie: "token123",
},
},
{
name: "Enabled_DeletesUnprefixedCookie",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
// Unprefixed cookie that should be in the "to prefix" list.
{Name: codersdk.SessionTokenCookie, Value: "unprefixed-token"},
},
expectedCookies: map[string]string{
// Session token should NOT be present - it was deleted.
},
expectedDeleted: []string{codersdk.SessionTokenCookie},
},
{
name: "Enabled_BothPrefixedAndUnprefixed",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
// Browser might send both during migration.
{Name: codersdk.SessionTokenCookie, Value: "unprefixed-token"},
{Name: "__Host-" + codersdk.SessionTokenCookie, Value: "prefixed-token"},
},
expectedCookies: map[string]string{
codersdk.SessionTokenCookie: "prefixed-token", // Prefixed wins.
},
expectedDeleted: []string{codersdk.SessionTokenCookie},
},
{
name: "Enabled_MultiplePrefixedCookies",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
{Name: "__Host-" + codersdk.SessionTokenCookie, Value: "session"},
{Name: "__Host-SomeOtherCookie", Value: "other-cookie"},
{Name: "__Host-Santa", Value: "santa"},
},
expectedCookies: map[string]string{
codersdk.SessionTokenCookie: "session",
"__Host-SomeOtherCookie": "other-cookie",
"__Host-Santa": "santa",
},
},
{
name: "Enabled_UnrelatedCookiesUnchanged",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
{Name: "custom_cookie", Value: "custom-value"},
{Name: "__Host-" + codersdk.SessionTokenCookie, Value: "session"},
{Name: "__Host-foobar", Value: "do-not-change-me"},
},
expectedCookies: map[string]string{
"custom_cookie": "custom-value",
codersdk.SessionTokenCookie: "session",
"__Host-foobar": "do-not-change-me",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var handlerCookies []*http.Cookie
handler := tc.cfg.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlerCookies = r.Cookies()
}))
req := httptest.NewRequest("GET", "/", nil)
for _, c := range baseCookies {
req.AddCookie(c)
}
for _, c := range tc.extraCookies {
req.AddCookie(c)
}
rw := httptest.NewRecorder()
handler.ServeHTTP(rw, req)
// Verify cookies seen by handler.
gotCookies := make(map[string]string)
for _, c := range handlerCookies {
gotCookies[c.Name] = c.Value
}
for _, v := range baseCookies {
tc.expectedCookies[v.Name] = v.Value
}
assert.Equal(t, tc.expectedCookies, gotCookies)
// Verify Set-Cookie header for deletion.
setCookies := rw.Result().Cookies()
if len(tc.expectedDeleted) > 0 {
assert.NotEmpty(t, setCookies, "expected Set-Cookie header for cookie deletion")
expDel := make(map[string]struct{})
for _, name := range tc.expectedDeleted {
expDel[name] = struct{}{}
}
// Verify it's a deletion (MaxAge < 0).
for _, c := range setCookies {
assert.Less(t, c.MaxAge, 0, "Set-Cookie should have MaxAge < 0 for deletion")
delete(expDel, c.Name)
}
require.Empty(t, expDel, "expected Set-Cookie header for deletion")
} else {
assert.Empty(t, setCookies, "did not expect Set-Cookie header")
}
})
}
}
func BenchmarkHTTPCookieConfigMiddleware(b *testing.B) {
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
// Realistic cookies that are always present in production.
baseCookies := []*http.Cookie{
{Name: "_ga", Value: "GA1.1.661026807.1770083336"},
{Name: "_ga_G0Q1B9GRC0", Value: "GS2.1.s1771343727$o49$g1$t1771343993$j48$l0$h0"},
{Name: "csrf_token", Value: "gDiKk8GjTM2iCUHAPfN9GlC+DGjzAprlLi2vJ+5TBU0="},
}
cases := []struct {
name string
cfg codersdk.HTTPCookieConfig
extraCookies []*http.Cookie
}{
{
name: "Disabled",
cfg: codersdk.HTTPCookieConfig{},
extraCookies: []*http.Cookie{
{Name: codersdk.SessionTokenCookie, Value: "KybJV9fNul-u11vlll9wiF6eLQDxBVucD"},
},
},
{
name: "Enabled_NoPrefixedCookies",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
{Name: codersdk.SessionTokenCookie, Value: "KybJV9fNul-u11vlll9wiF6eLQDxBVucD"},
},
},
{
name: "Enabled_WithPrefixedCookie",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
{Name: "__Host-" + codersdk.SessionTokenCookie, Value: "KybJV9fNul-u11vlll9wiF6eLQDxBVucD"},
},
},
{
name: "Enabled_MultiplePrefixedCookies",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
{Name: "__Host-" + codersdk.SessionTokenCookie, Value: "KybJV9fNul-u11vlll9wiF6eLQDxBVucD"},
{Name: "__Host-" + codersdk.PathAppSessionTokenCookie, Value: "xyz123"},
{Name: "__Host-" + codersdk.SubdomainAppSessionTokenCookie, Value: "abc456"},
{Name: "__Host-" + "foobar", Value: "do-not-change-me"},
},
},
{
name: "Enabled_NonSessionPrefixedCookies",
cfg: codersdk.HTTPCookieConfig{EnableHostPrefix: true},
extraCookies: []*http.Cookie{
{Name: "__Host-" + codersdk.SessionTokenCookie, Value: "KybJV9fNul-u11vlll9wiF6eLQDxBVucD"},
},
},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
handler := tc.cfg.Middleware(noop)
rw := httptest.NewRecorder()
allCookies := make([]*http.Cookie, 1, len(baseCookies))
copy(allCookies, baseCookies)
// Combine base cookies with test-specific cookies.
allCookies = append(allCookies, tc.extraCookies...)
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/", nil)
for _, c := range allCookies {
req.AddCookie(c)
}
handler.ServeHTTP(rw, req)
}
})
}
}
func TestExperimentsSafeAreKnown(t *testing.T) {
t.Parallel()
for _, ex := range codersdk.ExperimentsSafe {
require.Contains(t, codersdk.ExperimentsKnown, ex)
}
}