Skip to content
Merged
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
18 changes: 18 additions & 0 deletions ir/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ const (
AuthKindCustom AuthKind = "custom"
)

// Valid reports whether k is one of the mechanisms declared above. AuthKind is a
// bare string enum, so nothing rejects an empty, misspelled or stale value on
// the wire, and a scheme naming no mechanism is indistinguishable from one
// naming oauth2 to every structural check that reads only its key and its ID.
// irverify calls this so such a scheme is reported as the compiler bug it is.
func (k AuthKind) Valid() bool {
switch k {
case AuthKindAPIKey, AuthKindHTTPBasic, AuthKindHTTPBearer, AuthKindOAuth2,
AuthKindOpenIDConnect, AuthKindMutualTLS, AuthKindUserPassword, AuthKindX509,
AuthKindSymmetricEncryption, AuthKindAsymmetricEncryption,
AuthKindSASLPlain, AuthKindSASLSCRAMSHA256, AuthKindSASLSCRAMSHA512,
AuthKindSASLGSSAPI, AuthKindCustom:
return true
default:
return false
}
}

// AuthScheme is a named authentication scheme in Document.Auth (ir-design §9).
type AuthScheme struct {
// ID is the scheme's stable synthetic identity.
Expand Down
31 changes: 31 additions & 0 deletions ir/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/dexpace/morphic/ir"
)
Expand Down Expand Up @@ -70,6 +71,36 @@ func TestAuthKind_Constants(t *testing.T) {
}, "unspecified")
}

// TestAuthKind_TiesToConstBlock closes the gap a bare string enum leaves:
// nothing rejects an empty or misspelled mechanism on deserialization, and no
// structural check can tell one from oauth2 by reading a scheme's key and its
// ID, so irverify has Valid and nothing else to test against. Valid therefore
// has to stay tied to the const block, and it is tied here by parsing the ir
// sources rather than by a list.
//
// Adding a mechanism without teaching Valid about it fails here rather than
// surfacing later as a spurious ir/unknown-auth-kind on a document that names
// the mechanism correctly.
func TestAuthKind_TiesToConstBlock(t *testing.T) {
t.Parallel()
declared := declaredConstsOfType(t, "AuthKind")
require.NotEmpty(t, declared, "the ir sources must declare AuthKind constants")
for _, c := range declared {
assert.True(t, ir.AuthKind(c.value).Valid(), "declared mechanism %q must be Valid", c.value)
}
}

// TestAuthKind_UnknownIsInvalid pins the other direction: Valid must reject a
// mechanism no const declares. The empty string is the case that motivated the
// check — a scheme interned naming no mechanism at all — and the other two are
// the near misses a compiler writes by hand instead of using the constant.
func TestAuthKind_UnknownIsInvalid(t *testing.T) {
t.Parallel()
assert.False(t, ir.AuthKind("").Valid())
assert.False(t, ir.AuthKind("api_key").Valid())
assert.False(t, ir.AuthKind("OAuth2").Valid())
}

// TestOAuthFlow_ZeroValueShape pins OAuthFlow's omitempty contract: every
// field is optional, since only the flow kinds present in Scheme.Flows carry
// meaning for a given AuthScheme.
Expand Down
92 changes: 92 additions & 0 deletions ir/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ package ir_test

import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -45,6 +50,93 @@ func irSourceFiles(t *testing.T) []string {
return out
}

// parseIRSources parses every file irSourceFiles lists, under one FileSet.
func parseIRSources(t *testing.T) []*ast.File {
t.Helper()
paths := irSourceFiles(t)
fset := token.NewFileSet()
out := make([]*ast.File, 0, len(paths))
for _, path := range paths {
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
require.NoError(t, err, "parsing %s", path)
out = append(out, f)
}
require.NotEmpty(t, out, "the ir package must have production sources")
return out
}

// namedConst is one string constant as the ir sources declare it: the Go
// identifier and the wire value it spells.
type namedConst struct {
name string
value string
}

// declaredConstsOfType returns every constant of the named type the ir package's
// production sources declare, sorted by identifier.
//
// Deriving the set from the source is the point: any list of an enum's members
// written by hand is one commit away from disagreeing with the enum, and
// disagreeing silently. A bare string enum has no other guard — nothing rejects
// an undeclared value on the wire — so the tests that tie one to its Valid
// method all start here.
func declaredConstsOfType(t *testing.T, typeName string) []namedConst {
t.Helper()
var out []namedConst
for _, f := range parseIRSources(t) {
for _, decl := range f.Decls {
gd, isGen := decl.(*ast.GenDecl)
if !isGen || gd.Tok != token.CONST {
continue
}
out = append(out, constsOfType(t, gd, typeName)...)
}
}
slices.SortFunc(out, func(a, b namedConst) int { return strings.Compare(a.name, b.name) })
return out
}

// constsOfType returns the constants of the named type in one const group. A
// spec declaring neither type nor value repeats the previous spec, so the
// group's last explicit type carries forward; a spec with its own value declares
// its own type.
func constsOfType(t *testing.T, gd *ast.GenDecl, typeName string) []namedConst {
t.Helper()
var out []namedConst
isWanted := false
for _, spec := range gd.Specs {
vs, isValue := spec.(*ast.ValueSpec)
require.True(t, isValue, "const spec is not a ValueSpec: %#v", spec)
switch {
case vs.Type != nil:
id, isIdent := vs.Type.(*ast.Ident)
isWanted = isIdent && id.Name == typeName
case len(vs.Values) > 0:
isWanted = false
}
if !isWanted {
continue
}
for i, name := range vs.Names {
require.Less(t, i, len(vs.Values),
"%s constant %s must declare its own value", typeName, name.Name)
out = append(out, namedConst{name: name.Name, value: stringLit(t, name.Name, vs.Values[i])})
}
}
return out
}

// stringLit returns the string a constant's value expression spells out.
func stringLit(t *testing.T, constName string, expr ast.Expr) string {
t.Helper()
lit, isLit := expr.(*ast.BasicLit)
require.True(t, isLit, "constant %s must be declared as a string literal", constName)
require.Equal(t, token.STRING, lit.Kind, "constant %s must be declared as a string literal", constName)
unquoted, err := strconv.Unquote(lit.Value)
require.NoError(t, err, "unquoting the value of %s", constName)
return unquoted
}

// assertRoundTrip marshals want, unmarshals into a fresh T, and asserts the
// result equals want via cmp.Diff (never reflect.DeepEqual, per CLAUDE.md).
// This is the Class B (populated round-trip) workhorse shared by every
Expand Down
87 changes: 87 additions & 0 deletions ir/irverify/bigval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package irverify

import (
"reflect"
"strconv"

"github.com/dexpace/morphic/ir"
)

var (
bigValType = reflect.TypeFor[ir.BigVal]()
bigValPtrType = reflect.TypeFor[*ir.BigVal]()
)

// checkBigVals asserts every numeric literal the document carries is what
// ir.BigVal promises: a decimal literal that reads back as a JSON number, in the
// canonical form ir.NewBigVal produces.
//
// This is the same hazard checkRawPayloads covers for the other verbatim-text
// carrier, and it has the same shape: the text is copied wherever the value
// goes, and the grammar it must satisfy is enforced only at construction.
// ir.BigVal is a defined string type, so ir.BigVal(raw) compiles and skips
// ir.NewBigVal entirely, and — unlike the sealed TypeDef sum — it carries no
// UnmarshalJSON, so a document decoded from JSON never meets the constructor at
// all (GitHub #282). Round-tripping is not the safety net here: the value is
// carried faithfully precisely because it is a string.
//
// The two codes are separate because they name different repairs. A value the
// constructor rejects is not a number at all; a value it accepts but rewrites is
// a number spelled a way JSON does not admit — a leading "+", a redundant
// leading zero, a bare leading dot — which a consumer splicing the text into
// generated source or into JSON emits as invalid output.
//
// Reaching the literals through the walk rather than a list of carriers is what
// makes this complete: Constraints.Min, Max and MultipleOf and Value.Num are the
// fields today, and a numeric field added to the IR is covered the moment it
// exists.
func checkBigVals(doc *ir.Document, _ declarations) ([]Violation, bool) {
var vs []Violation
truncated := ir.WalkValues(doc, ir.DocumentPath, func(v reflect.Value, path string) bool {
switch v.Type() {
case bigValPtrType:
// A bound carried by pointer is present because something set it, so
// an empty literal there is a defect rather than an absence. Reading
// it here rather than descending is what tells the two apart.
if !v.IsNil() {
vs = appendBigVal(vs, v.Elem().String(), path)
}
return false
case bigValType:
// Value.Num is not a pointer and is the zero string on every Value
// that is not a number — most of the values a document holds — so an
// empty one here says the field is unused, not that it is broken.
if literal := v.String(); literal != "" {
vs = appendBigVal(vs, literal, path)
}
return false
default:
return true
}
})
return vs, truncated
}

// appendBigVal reports the two ways one literal can break ir.BigVal's contract.
// The literal is quoted because the values worth reporting are the ones that do
// not look like numbers, the empty string among them.
func appendBigVal(vs []Violation, literal, path string) []Violation {
canonical, err := ir.NewBigVal(literal)
if err != nil {
return append(vs, Violation{
Code: "ir/bigval-not-numeric",
Message: "numeric value " + strconv.Quote(literal) +
" is not a decimal literal, so it does not read back as a JSON number",
Path: path,
})
}
if string(canonical) == literal {
return vs
}
return append(vs, Violation{
Code: "ir/bigval-not-canonical",
Message: "numeric value " + strconv.Quote(literal) + " is not the JSON form " +
strconv.Quote(string(canonical)) + " ir.NewBigVal produces for it",
Path: path,
})
}
Loading
Loading