coderd/coderdtest/swagger_test.go
Rama main · réplica de lectura del repositorio original.
package coderdtest_test
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"slices"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/apidoc"
"github.com/coder/coder/v2/coderd/coderdtest"
)
// swaggerDoc renders the spec once. swag's ReadDoc writes shared state, so
// parallel tests that each call it race.
var swaggerDoc = sync.OnceValue(apidoc.SwaggerInfo.ReadDoc)
func TestEndpointsDocumented(t *testing.T) {
t.Parallel()
swaggerComments, err := coderdtest.ParseSwaggerComments("..", "../workspaceconnwatcher")
require.NoError(t, err, "can't parse swagger comments")
require.NotEmpty(t, swaggerComments, "swagger comments must be present")
_, _, api := coderdtest.NewWithAPI(t, nil)
coderdtest.VerifySwaggerDefinitions(t, api.APIHandler, swaggerComments, coderdtest.WithSwaggerRoutePrefix("/api/v2"))
}
func TestChatModelPathParametersFormatted(t *testing.T) {
t.Parallel()
var swagger struct {
Paths map[string]map[string]struct {
Parameters []struct {
Name string `json:"name"`
In string `json:"in"`
Format string `json:"format"`
} `json:"parameters"`
} `json:"paths"`
}
require.NoError(t, json.Unmarshal([]byte(swaggerDoc()), &swagger))
operations := swagger.Paths["/api/v2/organizations/{organization}/chats/models/{model}"]
for _, method := range []string{"get", "patch", "delete"} {
t.Run(method, func(t *testing.T) {
t.Parallel()
for _, parameter := range operations[method].Parameters {
if parameter.Name == "model" && parameter.In == "path" {
require.Equal(t, "uuid", parameter.Format)
return
}
}
require.Fail(t, "model path parameter not found")
})
}
}
// TestSwaggerParametersValid guards against parameter annotations that
// produce a wrong spec. Swagger 2.0 does not allow "example" on non-body
// parameters, swag writes format(...) on an array parameter to the array
// rather than its items, and a path parameter must appear in its route.
func TestSwaggerParametersValid(t *testing.T) {
t.Parallel()
var swagger struct {
Paths map[string]map[string]struct {
Parameters []struct {
Name string `json:"name"`
In string `json:"in"`
Type string `json:"type"`
Format string `json:"format"`
Example any `json:"example"`
} `json:"parameters"`
} `json:"paths"`
}
require.NoError(t, json.Unmarshal([]byte(swaggerDoc()), &swagger))
require.NotEmpty(t, swagger.Paths)
for path, operations := range swagger.Paths {
for method, operation := range operations {
for _, parameter := range operation.Parameters {
if parameter.In != "body" {
assert.Nil(t, parameter.Example, "%s %s: %s parameter %q must not have an example; use extensions(x-example=...)", method, path, parameter.In, parameter.Name)
}
if parameter.Type == "array" {
assert.Empty(t, parameter.Format, "%s %s: array parameter %q must not have a format; swag cannot set an item format, so state it in the description", method, path, parameter.Name)
}
if parameter.In == "path" {
assert.Contains(t, path, "{"+parameter.Name+"}", "%s %s: path parameter %q is not in the route", method, path, parameter.Name)
}
}
}
}
}
func TestSDKFieldsFormatted(t *testing.T) {
t.Parallel()
fileSet := token.NewFileSet()
nodes, err := parser.ParseDir(fileSet, "../../codersdk", nil, parser.ParseComments)
require.NoError(t, err, "parser.ParseDir failed")
for _, node := range nodes {
ast.Inspect(node, func(n ast.Node) bool {
typeSpec, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
structureName := typeSpec.Name
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return true // not a structure
}
for _, field := range structType.Fields.List {
selectorExpr, ok := field.Type.(*ast.SelectorExpr)
if !ok {
continue // rather a basic, or primitive
}
if field.Tag == nil || !strings.Contains(field.Tag.Value, `json:"`) {
continue // not a JSON property
}
switch selectorExpr.Sel.Name {
case "UUID":
assert.Contains(t, field.Tag.Value, `format:"uuid"`, `Swagger formatting requires to annotate the field with - format:"uuid". Location: %s/%s`, structureName, field.Names)
case "Time":
assert.Contains(t, field.Tag.Value, `format:"date-time"`, `Swagger formatting requires to annotate the field with - format:"date-time". Location: %s/%s`, structureName, field.Names)
}
}
return true
})
}
}
// TestSwaggerEnumsNotNarrowed checks every enum list in the spec. A property
// whose enum comes from an enums or oneof tag may list only values of its
// referenced type, and must list all of them unless the property is in
// allowedSubsets. No enum list may repeat a value, which Swagger 2.0 forbids.
func TestSwaggerEnumsNotNarrowed(t *testing.T) {
t.Parallel()
// Fields that omit a value of their type on purpose. The ACL role fields
// omit the "" deleted sentinel, which only update requests send.
// CreateWorkspaceBuildOnSuccessRequest.transition and
// UpdateUserAppearanceSettingsRequest.theme_mode are request fields whose
// validate oneof tag accepts only some values. APIKey.scope is deprecated
// and lists only the legacy scopes. Keep this list as small as possible.
allowedSubsets := map[string]bool{
"codersdk.APIKey.scope": true,
"codersdk.ChatGroup.role": true,
"codersdk.ChatProjectGroup.role": true,
"codersdk.ChatProjectUser.role": true,
"codersdk.ChatUser.role": true,
"codersdk.CreateWorkspaceBuildOnSuccessRequest.transition": true,
"codersdk.MCPServerConfigGroup.role": true,
"codersdk.MCPServerConfigUser.role": true,
"codersdk.OrganizationSkillGroup.role": true,
"codersdk.OrganizationSkillUser.role": true,
"codersdk.TemplateGroup.role": true,
"codersdk.TemplateUser.role": true,
"codersdk.UpdateUserAppearanceSettingsRequest.theme_mode": true,
"codersdk.WorkspaceGroup.role": true,
"codersdk.WorkspaceUser.role": true,
}
type schema struct {
Ref string `json:"$ref"`
AllOf []struct {
Ref string `json:"$ref"`
} `json:"allOf"`
Enum []any `json:"enum"`
Items *struct {
Ref string `json:"$ref"`
Enum []any `json:"enum"`
} `json:"items"`
}
var swagger struct {
Definitions map[string]struct {
Enum []any `json:"enum"`
Properties map[string]schema `json:"properties"`
} `json:"definitions"`
Paths map[string]map[string]struct {
Parameters []struct {
Name string `json:"name"`
Enum []any `json:"enum"`
Items *struct {
Enum []any `json:"enum"`
} `json:"items"`
} `json:"parameters"`
} `json:"paths"`
}
require.NoError(t, json.Unmarshal([]byte(apidoc.SwaggerInfo.ReadDoc()), &swagger))
assertNoDuplicates := func(where string, values []any) {
t.Helper()
seen := map[string]bool{}
for _, v := range enumStrings(values) {
assert.Falsef(t, seen[v], "%s lists enum value %q more than once", where, v)
seen[v] = true
}
}
seenSubsets := map[string]bool{}
assertEnumMatchesType := func(key, ref string, inline []any) {
t.Helper()
if ref == "" || len(inline) == 0 {
return
}
named := swagger.Definitions[strings.TrimPrefix(ref, "#/definitions/")].Enum
have := enumStrings(inline)
want := enumStrings(named)
for _, v := range have {
assert.Truef(t, slices.Contains(want, v), "%s lists %q, which type %s does not have; add the constant to the type if the API returns or accepts it, otherwise remove the value from the field's enums tag", key, v, ref)
}
if slices.Equal(have, want) {
return
}
seenSubsets[key] = true
assert.Truef(t, allowedSubsets[key], "%s lists %v but its type %s lists %v; remove the narrowing enums tag so the spec lists every value, or add the field to allowedSubsets if the subset is intentional, as for a validate oneof tag", key, inline, ref, named)
}
for defName, def := range swagger.Definitions {
assertNoDuplicates(defName, def.Enum)
for propName, prop := range def.Properties {
key := defName + "." + propName
assertNoDuplicates(key, prop.Enum)
ref := prop.Ref
if ref == "" && len(prop.AllOf) > 0 {
ref = prop.AllOf[0].Ref
}
assertEnumMatchesType(key, ref, prop.Enum)
if prop.Items != nil {
assertNoDuplicates(key, prop.Items.Enum)
assertEnumMatchesType(key, prop.Items.Ref, prop.Items.Enum)
}
}
}
for key := range allowedSubsets {
assert.Truef(t, seenSubsets[key], "%s is in allowedSubsets but is no longer a subset of its type; remove it", key)
}
for route, methods := range swagger.Paths {
for method, op := range methods {
for _, param := range op.Parameters {
where := method + " " + route + " parameter " + param.Name
assertNoDuplicates(where, param.Enum)
if param.Items != nil {
assertNoDuplicates(where, param.Items.Enum)
}
}
}
}
}
// enumStrings returns the sorted string form of enum values, which may be numbers.
func enumStrings(values []any) []string {
out := make([]string, 0, len(values))
for _, v := range values {
out = append(out, fmt.Sprint(v))
}
slices.Sort(out)
return out
}