snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"type": "a",
"group": "default",
"attributes": {
"href": "https://example.com",
"title": null,
"id": "",
"node_type": "a"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,10 @@
{
"type": "div",
"group": "default",
"attributes": {
"id": "",
"node_type": "div"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,12 @@
{
"type": "input",
"group": "default",
"attributes": {
"name": "email",
"type": "",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,13 @@
{
"type": "img",
"group": "default",
"attributes": {
"src": "image.jpg",
"id": "",
"width": 0,
"height": 0,
"node_type": "img"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,13 @@
{
"type": "input",
"group": "default",
"attributes": {
"name": "password",
"type": "password",
"value": "secret",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,17 @@
{
"type": "script",
"group": "default",
"attributes": {
"src": "script.js",
"async": false,
"referrerpolicy": "",
"crossorigin": "",
"integrity": "",
"type": "",
"id": "",
"nonce": "",
"node_type": "script"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,11 @@
{
"type": "text",
"group": "default",
"attributes": {
"text": null,
"id": "",
"node_type": "text"
},
"messages": [],
"meta": {}
}

542
ui/node/attributes.go Normal file
View File

@@ -0,0 +1,542 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
import (
"fmt"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x/webauthnx/js"
)
const (
InputAttributeTypeText UiNodeInputAttributeType = "text"
InputAttributeTypePassword UiNodeInputAttributeType = "password"
InputAttributeTypeNumber UiNodeInputAttributeType = "number"
InputAttributeTypeCheckbox UiNodeInputAttributeType = "checkbox"
InputAttributeTypeHidden UiNodeInputAttributeType = "hidden"
InputAttributeTypeEmail UiNodeInputAttributeType = "email"
InputAttributeTypeTel UiNodeInputAttributeType = "tel"
InputAttributeTypeSubmit UiNodeInputAttributeType = "submit"
InputAttributeTypeButton UiNodeInputAttributeType = "button"
InputAttributeTypeDateTimeLocal UiNodeInputAttributeType = "datetime-local"
InputAttributeTypeDate UiNodeInputAttributeType = "date"
InputAttributeTypeURI UiNodeInputAttributeType = "url"
)
const (
InputAttributeAutocompleteEmail UiNodeInputAttributeAutocomplete = "email"
InputAttributeAutocompleteTel UiNodeInputAttributeAutocomplete = "tel"
InputAttributeAutocompleteUrl UiNodeInputAttributeAutocomplete = "url"
InputAttributeAutocompleteCurrentPassword UiNodeInputAttributeAutocomplete = "current-password"
InputAttributeAutocompleteNewPassword UiNodeInputAttributeAutocomplete = "new-password"
InputAttributeAutocompleteOneTimeCode UiNodeInputAttributeAutocomplete = "one-time-code"
InputAttributeAutocompleteUsername UiNodeInputAttributeAutocomplete = "username"
InputAttributeAutocompleteUsernameWebauthn UiNodeInputAttributeAutocomplete = "username webauthn"
)
// swagger:enum UiNodeInputAttributeType
type UiNodeInputAttributeType string
// swagger:enum UiNodeInputAttributeAutocomplete
type UiNodeInputAttributeAutocomplete string
// Attributes represents a list of attributes (e.g. `href="foo"` for links).
//
// swagger:model uiNodeAttributes
type Attributes interface {
// swagger:ignore
ID() string
// swagger:ignore
Reset()
// swagger:ignore
SetValue(value interface{})
// swagger:ignore
GetValue() interface{}
// swagger:ignore
GetNodeType() UiNodeType
// swagger:ignore
Matches(other Attributes) bool
}
// InputAttributes represents the attributes of an input node
//
// swagger:model uiNodeInputAttributes
type InputAttributes struct {
// The input's element name.
//
// required: true
Name string `json:"name"`
// The input's element type.
//
// required: true
Type UiNodeInputAttributeType `json:"type" faker:"-"`
// The input's value.
FieldValue interface{} `json:"value,omitempty" faker:"string"`
// Mark this input field as required.
Required bool `json:"required,omitempty"`
// The autocomplete attribute for the input.
Autocomplete UiNodeInputAttributeAutocomplete `json:"autocomplete,omitempty"`
// The input's label text.
Label *text.Message `json:"label,omitempty"`
// The input's pattern.
Pattern string `json:"pattern,omitempty"`
// Sets the input's disabled field to true or false.
//
// required: true
Disabled bool `json:"disabled"`
// OnClick may contain javascript which should be executed on click. This is primarily
// used for WebAuthn.
//
// Deprecated: Using OnClick requires the use of eval() which is a security risk. Use OnClickTrigger instead.
OnClick string `json:"onclick,omitempty"`
// OnClickTrigger may contain a WebAuthn trigger which should be executed on click.
//
// The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.
OnClickTrigger js.WebAuthnTriggers `json:"onclickTrigger,omitempty"`
// OnLoad may contain javascript which should be executed on load. This is primarily
// used for WebAuthn.
//
// Deprecated: Using OnLoad requires the use of eval() which is a security risk. Use OnLoadTrigger instead.
OnLoad string `json:"onload,omitempty"`
// OnLoadTrigger may contain a WebAuthn trigger which should be executed on load.
//
// The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.
OnLoadTrigger js.WebAuthnTriggers `json:"onloadTrigger,omitempty"`
// MaxLength may contain the input's maximum length.
MaxLength int `json:"maxlength,omitempty"`
// The allowed values for the input when the underlying JSON schema
// defines an `enum`. When present, clients should render the field as a
// select/dropdown rather than a free-form text input. When absent,
// clients continue to render a plain text input, so this field is
// backward compatible with UIs that do not look at it.
Options []InputAttributesOption `json:"options,omitempty" faker:"-"`
// NodeType represents this node's types. It is a mirror of `node.type` and
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "input".
//
// required: true
NodeType UiNodeType `json:"node_type"`
}
// Represents a single selectable value for an input whose JSON schema
// defined an `enum`. The value is always a scalar JSON type (string, number,
// or boolean) serialized verbatim from the schema. When present, clients
// should render the parent input as a select/dropdown.
//
// swagger:model uiNodeInputAttributesOption
type InputAttributesOption struct {
// The value that will be submitted when this option is picked.
// It is serialized verbatim from the JSON schema `enum` entry, so it is
// always a scalar JSON value (string, number, or boolean).
//
// required: true
Value any `json:"value"`
}
// ImageAttributes represents the attributes of an image node.
//
// swagger:model uiNodeImageAttributes
type ImageAttributes struct {
// The image's source URL.
//
// format: uri
// required: true
Source string `json:"src"`
// A unique identifier
//
// required: true
Identifier string `json:"id"`
// Width of the image
//
// required: true
Width int `json:"width"`
// Height of the image
//
// required: true
Height int `json:"height"`
// NodeType represents this node's types. It is a mirror of `node.type` and
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "img".
//
// required: true
NodeType UiNodeType `json:"node_type"`
}
// AnchorAttributes represents the attributes of an anchor node.
//
// swagger:model uiNodeAnchorAttributes
type AnchorAttributes struct {
// The link's href (destination) URL.
//
// format: uri
// required: true
HREF string `json:"href"`
// The link's title.
//
// required: true
Title *text.Message `json:"title"`
// A unique identifier
//
// required: true
Identifier string `json:"id"`
// NodeType represents this node's types. It is a mirror of `node.type` and
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "a".
//
// required: true
NodeType UiNodeType `json:"node_type"`
}
// TextAttributes represents the attributes of a text node.
//
// swagger:model uiNodeTextAttributes
type TextAttributes struct {
// The text of the text node.
//
// required: true
Text *text.Message `json:"text"`
// A unique identifier
//
// required: true
Identifier string `json:"id"`
// NodeType represents this node's types. It is a mirror of `node.type` and
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "text".
//
// required: true
NodeType UiNodeType `json:"node_type"`
}
// ScriptAttributes represent script nodes which load javascript.
//
// swagger:model uiNodeScriptAttributes
type ScriptAttributes struct {
// The script source
//
// required: true
Source string `json:"src"`
// The script async type
//
// required: true
Async bool `json:"async"`
// The script referrer policy
//
// required: true
ReferrerPolicy string `json:"referrerpolicy"`
// The script cross origin policy
//
// required: true
CrossOrigin string `json:"crossorigin"`
// The script's integrity hash
//
// required: true
Integrity string `json:"integrity"`
// The script MIME type
//
// required: true
Type string `json:"type"`
// A unique identifier
//
// required: true
Identifier string `json:"id"`
// Nonce for CSP
//
// A nonce you may want to use to improve your Content Security Policy.
// You do not have to use this value but if you want to improve your CSP
// policies you may use it. You can also choose to use your own nonce value!
//
// required: true
Nonce string `json:"nonce"`
// NodeType represents this node's types. It is a mirror of `node.type` and
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "script".
//
// required: true
NodeType UiNodeType `json:"node_type"`
}
// DivisionAttributes represent a division section.
//
// Division sections are used for interactive widgets that require a hook in the DOM / view.
//
// swagger:model uiNodeDivisionAttributes
type DivisionAttributes struct {
// A classname that should be rendered into the DOM.
Classname string `json:"class,omitzero"`
// A unique identifier
//
// required: true
Identifier string `json:"id"`
// Data is a map of key-value pairs that are passed to the division.
//
// They may be used for `data-...` attributes.
Data map[string]string `json:"data,omitzero"`
// NodeType represents this node's type. It is a mirror of `node.type` and
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "script".
//
// required: true
NodeType UiNodeType `json:"node_type"`
}
func (d DivisionAttributes) ID() string {
return d.Identifier
}
func (d DivisionAttributes) Reset() {}
func (d DivisionAttributes) SetValue(_ interface{}) {}
func (d DivisionAttributes) GetValue() interface{} {
return nil
}
func (d DivisionAttributes) GetNodeType() UiNodeType {
return d.NodeType
}
func (d DivisionAttributes) Matches(other Attributes) bool {
ot, ok := other.(*DivisionAttributes)
if !ok {
return false
}
if len(ot.ID()) > 0 && d.ID() != ot.ID() {
return false
}
return true
}
var (
_ Attributes = new(InputAttributes)
_ Attributes = new(ImageAttributes)
_ Attributes = new(AnchorAttributes)
_ Attributes = new(TextAttributes)
_ Attributes = new(ScriptAttributes)
_ Attributes = new(DivisionAttributes)
)
func (a *InputAttributes) ID() string {
return a.Name
}
func (a *ImageAttributes) ID() string {
return a.Identifier
}
func (a *AnchorAttributes) ID() string {
return a.Identifier
}
func (a *TextAttributes) ID() string {
return a.Identifier
}
func (a *ScriptAttributes) ID() string {
return a.Identifier
}
func (a *InputAttributes) Matches(other Attributes) bool {
ot, ok := other.(*InputAttributes)
if !ok {
return false
}
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
return false
}
if len(ot.Type) > 0 && a.Type != ot.Type {
return false
}
if ot.FieldValue != nil && fmt.Sprintf("%v", a.FieldValue) != fmt.Sprintf("%v", ot.FieldValue) {
return false
}
if len(ot.Name) > 0 && a.Name != ot.Name {
return false
}
return true
}
func (a *ImageAttributes) Matches(other Attributes) bool {
ot, ok := other.(*ImageAttributes)
if !ok {
return false
}
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
return false
}
if len(ot.Source) > 0 && a.Source != ot.Source {
return false
}
return true
}
func (a *AnchorAttributes) Matches(other Attributes) bool {
ot, ok := other.(*AnchorAttributes)
if !ok {
return false
}
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
return false
}
if len(ot.HREF) > 0 && a.HREF != ot.HREF {
return false
}
return true
}
func (a *TextAttributes) Matches(other Attributes) bool {
ot, ok := other.(*TextAttributes)
if !ok {
return false
}
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
return false
}
return true
}
func (a *ScriptAttributes) Matches(other Attributes) bool {
ot, ok := other.(*ScriptAttributes)
if !ok {
return false
}
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
return false
}
if ot.Type != "" && a.Type != ot.Type {
return false
}
if ot.Source != "" && a.Source != ot.Source {
return false
}
return true
}
func (a *InputAttributes) SetValue(value interface{}) {
a.FieldValue = value
}
func (a *ImageAttributes) SetValue(value interface{}) {
a.Source, _ = value.(string)
}
func (a *AnchorAttributes) SetValue(value interface{}) {
a.HREF, _ = value.(string)
}
func (a *TextAttributes) SetValue(value interface{}) {
a.Text, _ = value.(*text.Message)
}
func (a *ScriptAttributes) SetValue(value interface{}) {
a.Source, _ = value.(string)
}
func (a *InputAttributes) GetValue() interface{} {
return a.FieldValue
}
func (a *ImageAttributes) GetValue() interface{} {
return a.Source
}
func (a *AnchorAttributes) GetValue() interface{} {
return a.HREF
}
func (a *TextAttributes) GetValue() interface{} {
return a.Text
}
func (a *ScriptAttributes) GetValue() interface{} {
return a.Source
}
func (a *InputAttributes) Reset() {
a.FieldValue = nil
}
func (a *ImageAttributes) Reset() {
}
func (a *AnchorAttributes) Reset() {
}
func (a *TextAttributes) Reset() {
}
func (a *ScriptAttributes) Reset() {
}
func (a *InputAttributes) GetNodeType() UiNodeType {
return UiNodeType(a.NodeType)
}
func (a *ImageAttributes) GetNodeType() UiNodeType {
return UiNodeType(a.NodeType)
}
func (a *AnchorAttributes) GetNodeType() UiNodeType {
return UiNodeType(a.NodeType)
}
func (a *TextAttributes) GetNodeType() UiNodeType {
return UiNodeType(a.NodeType)
}
func (a *ScriptAttributes) GetNodeType() UiNodeType {
return UiNodeType(a.NodeType)
}

251
ui/node/attributes_input.go Normal file
View File

@@ -0,0 +1,251 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
import (
"encoding/json"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/jsonschemax"
)
const DisableFormField = "disableFormField"
// maxEnumOptions caps how many `enum` values a single identity-schema
// property may surface as a selectable option on a UI node. The cap bounds
// the size of flow responses when an operator (accidentally or otherwise)
// declares a very large enum, so a single misconfigured schema cannot
// inflate every active registration/settings/login flow on a tenant.
const maxEnumOptions = 500
func toFormType(n string, i interface{}) UiNodeInputAttributeType {
switch n {
case nosurfx.CSRFTokenName:
return InputAttributeTypeHidden
case "password":
return InputAttributeTypePassword
}
switch i.(type) {
case float64, int64, int32, float32, json.Number:
return InputAttributeTypeNumber
case bool:
return InputAttributeTypeCheckbox
}
return InputAttributeTypeText
}
type (
InputAttributesModifier func(attributes *InputAttributes)
InputAttributesModifiers []InputAttributesModifier
)
func WithRequiredInputAttribute(a *InputAttributes) {
a.Required = true
}
func WithMaxLengthInputAttribute(maxLength int) func(a *InputAttributes) {
return func(a *InputAttributes) {
a.MaxLength = maxLength
}
}
func WithInputAttributes(f func(a *InputAttributes)) func(a *InputAttributes) {
return func(a *InputAttributes) {
f(a)
}
}
func applyInputAttributes(opts []InputAttributesModifier, attributes *InputAttributes) *InputAttributes {
for _, f := range opts {
f(attributes)
}
return attributes
}
type (
ImageAttributesModifier func(attributes *ImageAttributes)
ImageAttributesModifiers []ImageAttributesModifier
)
func WithImageAttributes(f func(a *ImageAttributes)) func(a *ImageAttributes) {
return func(a *ImageAttributes) {
f(a)
}
}
func applyImageAttributes(opts ImageAttributesModifiers, attributes *ImageAttributes) *ImageAttributes {
for _, f := range opts {
f(attributes)
}
return attributes
}
type (
ScriptAttributesModifier func(attributes *ScriptAttributes)
ScriptAttributesModifiers []ScriptAttributesModifier
)
func applyScriptAttributes(opts ScriptAttributesModifiers, attributes *ScriptAttributes) *ScriptAttributes {
for _, f := range opts {
f(attributes)
}
return attributes
}
type (
DivisionAttributesModifier func(attributes *DivisionAttributes)
DivisionAttributesModifiers []DivisionAttributesModifier
)
func WithDivisionAttributes(f func(a *DivisionAttributes)) func(a *DivisionAttributes) {
return func(a *DivisionAttributes) {
f(a)
}
}
func applyDivisionAttributes(opts DivisionAttributesModifiers, attributes *DivisionAttributes) *DivisionAttributes {
for _, f := range opts {
f(attributes)
}
return attributes
}
func NewInputFieldFromJSON(name string, value interface{}, group UiNodeGroup, opts ...InputAttributesModifier) *Node {
return &Node{
Type: Input,
Group: group,
Attributes: applyInputAttributes(opts, &InputAttributes{Name: name, Type: toFormType(name, value), FieldValue: value}),
Meta: &Meta{},
}
}
func NewInputField(name string, value interface{}, group UiNodeGroup, inputType UiNodeInputAttributeType, opts ...InputAttributesModifier) *Node {
return &Node{
Type: Input,
Group: group,
Attributes: applyInputAttributes(opts, &InputAttributes{Name: name, Type: inputType, FieldValue: value}),
Meta: &Meta{},
}
}
func NewImageField(id string, src string, group UiNodeGroup, opts ...ImageAttributesModifier) *Node {
return &Node{
Type: Image,
Group: group,
Attributes: applyImageAttributes(opts, &ImageAttributes{Source: src, Identifier: id}),
Meta: &Meta{},
}
}
func NewTextField(id string, text *text.Message, group UiNodeGroup) *Node {
return &Node{
Type: Text,
Group: group,
Attributes: &TextAttributes{Text: text, Identifier: id},
Meta: &Meta{},
}
}
func NewDivisionField(id string, group UiNodeGroup, opts ...DivisionAttributesModifier) *Node {
return &Node{
Type: Division,
Group: group,
Attributes: applyDivisionAttributes(opts, &DivisionAttributes{Identifier: id}),
Meta: &Meta{},
}
}
func NewAnchorField(id string, href string, group UiNodeGroup, title *text.Message) *Node {
return &Node{
Type: Anchor,
Group: group,
Attributes: &AnchorAttributes{Title: title, HREF: href, Identifier: id},
Meta: &Meta{},
}
}
func NewScriptField(name string, src string, group UiNodeGroup, integrity string, opts ...ScriptAttributesModifier) *Node {
return &Node{
Type: Script,
Group: group,
Attributes: applyScriptAttributes(opts, &ScriptAttributes{
Identifier: name,
Type: "text/javascript",
Source: src,
Async: true,
ReferrerPolicy: "no-referrer",
CrossOrigin: "anonymous",
Integrity: integrity,
Nonce: x.NewUUID().String(),
}),
Meta: &Meta{},
}
}
func NewInputFieldFromSchema(name string, group UiNodeGroup, p jsonschemax.Path, opts ...InputAttributesModifier) *Node {
attr := &InputAttributes{
Name: name,
Type: toFormType(p.Name, p.Type),
Required: p.Required,
}
// If format is set, we can make a more distinct decision:
switch p.Format {
case "date-time":
attr.Type = InputAttributeTypeDateTimeLocal
case "email":
attr.Type = InputAttributeTypeEmail
attr.Autocomplete = InputAttributeAutocompleteEmail
case "tel":
attr.Type = InputAttributeTypeTel
attr.Autocomplete = InputAttributeAutocompleteTel
case "date":
attr.Type = InputAttributeTypeDate
case "uri":
attr.Type = InputAttributeTypeURI
attr.Autocomplete = InputAttributeAutocompleteUrl
case "regex":
attr.Type = InputAttributeTypeText
}
// Enum values become selectable options. The presence of options is the
// signal for consumers to render a dropdown instead of a free-form input.
// Cap the number of options to keep a pathological schema from inflating
// every flow response.
if len(p.Enum) > 0 {
n := min(len(p.Enum), maxEnumOptions)
attr.Options = make([]InputAttributesOption, n)
for i, v := range p.Enum[:n] {
attr.Options[i] = InputAttributesOption{Value: v}
}
}
// Other properties
if p.Pattern != nil {
attr.Pattern = p.Pattern.String()
}
// Set disabled if the custom property is set
if isDisabled, ok := p.CustomProperties[DisableFormField]; ok {
if isDisabled, ok := isDisabled.(bool); ok {
attr.Disabled = isDisabled
}
}
var meta Meta
if len(p.Title) > 0 {
meta.Label = text.NewInfoNodeLabelGenerated(p.Title, name)
}
return &Node{
Type: Input,
Attributes: applyInputAttributes(opts, attr),
Group: group,
Meta: &meta,
}
}

View File

@@ -0,0 +1,19 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
import "github.com/ory/kratos/x/nosurfx"
func NewCSRFNode(token string) *Node {
return &Node{
Type: Input,
Group: DefaultGroup,
Attributes: &InputAttributes{
Name: nosurfx.CSRFTokenName,
Type: InputAttributeTypeHidden,
FieldValue: token,
Required: true,
},
}
}

View File

@@ -0,0 +1,100 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
import (
"bytes"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/jsonschema/v3"
"github.com/ory/x/jsonschemax"
)
func TestFieldFromPath(t *testing.T) {
t.Parallel()
ctx := t.Context()
t.Run("all properties are properly transferred", func(t *testing.T) {
t.Parallel()
schema, err := os.ReadFile("./fixtures/all_formats.schema.json")
require.NoError(t, err)
c := jsonschema.NewCompiler()
require.NoError(t, c.AddResource("test.json", bytes.NewBuffer(schema)))
paths, err := jsonschemax.ListPaths(ctx, "test.json", c)
require.NoError(t, err)
for _, path := range paths {
node := NewInputFieldFromSchema(path.Name, DefaultGroup, path)
assert.EqualValues(t, "input", node.Type)
require.IsType(t, new(InputAttributes), node.Attributes)
attr := node.Attributes.(*InputAttributes)
assert.EqualValues(t, gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_type", path.Name)).String(), attr.Type)
assert.True(t, !gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_pattern", path.Name)).Exists() ||
(gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_pattern", path.Name)).Bool() && attr.Pattern != ""))
expectedAutocomplete := gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_autocomplete", path.Name))
if expectedAutocomplete.Exists() {
assert.EqualValues(t, expectedAutocomplete.String(), attr.Autocomplete)
}
expectedOptions := gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_options", path.Name))
if expectedOptions.Exists() {
// Compare through JSON so string, number, and boolean
// option values all survive the round-trip without relying
// on a lossy `%v` coercion.
var got []any
for _, o := range attr.Options {
got = append(got, o.Value)
}
gotJSON, err := json.Marshal(got)
require.NoError(t, err)
assert.JSONEq(t, expectedOptions.Raw, string(gotJSON), "field %s", path.Name)
} else {
assert.Empty(t, attr.Options, "field %s should have no options", path.Name)
}
}
})
t.Run("caps enum options at maxEnumOptions", func(t *testing.T) {
t.Parallel()
enum := make([]any, maxEnumOptions+50)
for i := range enum {
enum[i] = fmt.Sprintf("v%d", i)
}
node := NewInputFieldFromSchema("field", DefaultGroup, jsonschemax.Path{
Name: "field",
Type: "",
Enum: enum,
})
attr := node.Attributes.(*InputAttributes)
assert.Len(t, attr.Options, maxEnumOptions)
assert.Equal(t, "v0", attr.Options[0].Value)
assert.Equal(t, fmt.Sprintf("v%d", maxEnumOptions-1), attr.Options[maxEnumOptions-1].Value)
})
t.Run("empty enum produces no options", func(t *testing.T) {
t.Parallel()
node := NewInputFieldFromSchema("field", DefaultGroup, jsonschemax.Path{
Name: "field",
Type: "",
Enum: []any{},
})
attr := node.Attributes.(*InputAttributes)
assert.Empty(t, attr.Options)
})
}

124
ui/node/attributes_test.go Normal file
View File

@@ -0,0 +1,124 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
import (
"encoding/json"
"testing"
"github.com/tidwall/gjson"
"github.com/ory/x/jsonx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIDs(t *testing.T) {
assert.EqualValues(t, "foo", (&AnchorAttributes{Identifier: "foo"}).ID())
assert.EqualValues(t, "foo", (&ImageAttributes{Identifier: "foo"}).ID())
assert.EqualValues(t, "foo", (&TextAttributes{Identifier: "foo"}).ID())
assert.EqualValues(t, "foo", (&InputAttributes{Name: "foo"}).ID())
assert.EqualValues(t, "foo", (&ScriptAttributes{Identifier: "foo"}).ID())
}
func TestMatchesAnchorAttributes(t *testing.T) {
assert.True(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&AnchorAttributes{Identifier: "foo"}))
assert.True(t, (&AnchorAttributes{HREF: "bar"}).Matches(&AnchorAttributes{HREF: "bar"}))
assert.False(t, (&AnchorAttributes{HREF: "foo"}).Matches(&AnchorAttributes{HREF: "bar"}))
assert.False(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&AnchorAttributes{HREF: "bar"}))
assert.True(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "foo", HREF: "bar"}))
assert.False(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "foo", HREF: "baz"}))
assert.False(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "bar", HREF: "bar"}))
assert.False(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
}
func TestMatchesImageAttributes(t *testing.T) {
assert.True(t, (&ImageAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Identifier: "foo"}))
assert.True(t, (&ImageAttributes{Source: "bar"}).Matches(&ImageAttributes{Source: "bar"}))
assert.False(t, (&ImageAttributes{Source: "foo"}).Matches(&ImageAttributes{Source: "bar"}))
assert.False(t, (&ImageAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Source: "bar"}))
assert.True(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "foo", Source: "bar"}))
assert.False(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "foo", Source: "baz"}))
assert.False(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "bar", Source: "bar"}))
assert.False(t, (&ImageAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
}
func TestMatchesInputAttributes(t *testing.T) {
// Test when other is not of type *InputAttributes
var attr Attributes = &ImageAttributes{}
inputAttr := &InputAttributes{Name: "foo"}
assert.False(t, inputAttr.Matches(attr))
// Test when ID is different
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText}
inputAttr = &InputAttributes{Name: "bar", Type: InputAttributeTypeText}
assert.False(t, inputAttr.Matches(attr))
// Test when Type is different
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText}
inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeNumber}
assert.False(t, inputAttr.Matches(attr))
// Test when FieldValue is different
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"}
inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "baz"}
assert.False(t, inputAttr.Matches(attr))
// Test when Name is different
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText}
inputAttr = &InputAttributes{Name: "bar", Type: InputAttributeTypeText}
assert.False(t, inputAttr.Matches(attr))
// Test when all fields are the same
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"}
inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"}
assert.True(t, inputAttr.Matches(attr))
}
func TestMatchesTextAttributes(t *testing.T) {
assert.True(t, (&TextAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
assert.True(t, (&TextAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
assert.False(t, (&TextAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Identifier: "foo"}))
}
func TestNodeEncode(t *testing.T) {
script := jsonx.TestMarshalJSONString(t, &Node{Attributes: &ScriptAttributes{}})
assert.EqualValues(t, Script, gjson.Get(script, "attributes.node_type").String())
assert.EqualValues(t, Script, gjson.Get(script, "type").String())
text := jsonx.TestMarshalJSONString(t, &Node{Attributes: &TextAttributes{}})
assert.EqualValues(t, Text, gjson.Get(text, "attributes.node_type").String())
assert.EqualValues(t, Text, gjson.Get(text, "type").String())
image := jsonx.TestMarshalJSONString(t, &Node{Attributes: &ImageAttributes{}})
assert.EqualValues(t, Image, gjson.Get(image, "attributes.node_type").String())
assert.EqualValues(t, Image, gjson.Get(image, "type").String())
input := jsonx.TestMarshalJSONString(t, &Node{Attributes: &InputAttributes{}})
assert.EqualValues(t, Input, gjson.Get(input, "attributes.node_type").String())
assert.EqualValues(t, Input, gjson.Get(input, "type").String())
anchor := jsonx.TestMarshalJSONString(t, &Node{Attributes: &AnchorAttributes{}})
assert.EqualValues(t, Anchor, gjson.Get(anchor, "attributes.node_type").String())
assert.EqualValues(t, Anchor, gjson.Get(anchor, "type").String())
}
func TestNodeDecode(t *testing.T) {
for _, kind := range []UiNodeType{
Text,
Input,
Image,
Anchor,
Script,
} {
var n Node
require.NoError(t, json.Unmarshal([]byte(`{"type":"`+kind+`"}`), &n))
assert.EqualValues(t, kind, n.Attributes.GetNodeType())
}
}

View File

@@ -0,0 +1,92 @@
{
"$id": "https://example.com/all_formats.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"password": {
"type": "string",
"test_expected_type": "password"
},
"csrf_token": {
"type": "string",
"test_expected_type": "hidden"
},
"simpleString": {
"type": "string",
"test_expected_type": "text"
},
"simpleNumber": {
"type": "number",
"test_expected_type": "number"
},
"simpleBoolean": {
"type": "boolean",
"test_expected_type": "checkbox"
},
"emailString": {
"type": "string",
"format": "email",
"test_expected_type": "email",
"test_expected_autocomplete": "email"
},
"phoneString": {
"type": "string",
"format": "tel",
"test_expected_type": "tel",
"test_expected_autocomplete": "tel"
},
"dateTimeString": {
"type": "string",
"format": "date-time",
"test_expected_type": "datetime-local"
},
"regexString": {
"type": "string",
"format": "regex",
"test_expected_type": "text"
},
"dateString": {
"type": "string",
"format": "date",
"test_expected_type": "date"
},
"uriString": {
"type": "string",
"format": "uri",
"test_expected_type": "url",
"test_expected_autocomplete": "url"
},
"patternString": {
"type": "string",
"pattern": "foo.*",
"test_expected_type": "text",
"test_expected_pattern": true
},
"enumString": {
"type": "string",
"enum": ["one", "two", "three"],
"test_expected_type": "text",
"test_expected_options": ["one", "two", "three"]
},
"enumNumber": {
"type": "number",
"enum": [1, 2.5, 3],
"test_expected_type": "number",
"test_expected_options": [1, 2.5, 3]
},
"enumBoolean": {
"type": "boolean",
"enum": [true, false],
"test_expected_type": "checkbox",
"test_expected_options": [true, false]
},
"enumEmail": {
"type": "string",
"format": "email",
"enum": ["admin@example.com", "user@example.com"],
"test_expected_type": "email",
"test_expected_autocomplete": "email",
"test_expected_options": ["admin@example.com", "user@example.com"]
}
}
}

View File

@@ -0,0 +1,52 @@
{
"$id": "https://example.com/complex.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["meal"],
"properties": {
"meal": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"chef": {
"type": "string"
}
}
},
"fruits": {
"type": "array",
"items": {
"type": "string"
}
},
"vegetables": {
"type": "array",
"items": {
"$ref": "#/definitions/veggie"
}
}
},
"definitions": {
"veggie": {
"type": "object",
"required": [
"veggieName",
"veggieLike"
],
"properties": {
"veggieName": {
"type": "string"
},
"veggieLike": {
"type": "boolean"
},
"veggieAmount": {
"type": "number"
}
}
}
}
}

View File

@@ -0,0 +1,43 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
}
}
},
"stringy": {
"type": "string"
},
"numby": {
"type": "number"
},
"booly": {
"type": "boolean"
},
"should_big_number": {
"type": "number",
"minimum": 1200
},
"should_long_string": {
"type": "string",
"minLength": 25
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"stringy": {
"type": "string"
},
"numby": {
"type": "number"
},
"objy": {
"type": "object",
"properties": {
"stringy": {
"type": "string"
},
"numby": {
"type": "number"
},
"objy": {
"type": "object",
"properties": {}
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "Sk3WySQDVpL5V9IuQelZTXcRVbBE1STI0HAJEcHOnM0/xPz5p1piBzuBMKZWxgYJc5TN8k8BTM9Hazrf/n76pg==",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "github",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "google",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "hydra",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "method",
"type": "submit",
"value": "oidc",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password_identifier",
"type": "text",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,118 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "github",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "google",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "hydra",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "method",
"type": "submit",
"value": "oidc",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.abcd",
"type": "input",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.email",
"type": "email",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,105 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "github",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "google",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "hydra",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "method",
"type": "submit",
"value": "oidc",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.abcd",
"type": "input",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.email",
"type": "email",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,164 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.email",
"type": "email",
"value": "john@doe.com",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "traits.name",
"type": "text",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "profile",
"attributes": {
"name": "method",
"type": "submit",
"value": "profile",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
}
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "link",
"type": "submit",
"value": "github",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050002,
"text": "Link github",
"type": "info",
"context": {
"provider": "github"
}
}
}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "unlink",
"type": "submit",
"value": "google",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050003,
"text": "Unlink google",
"type": "info",
"context": {
"provider": "google"
}
}
}
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "link",
"type": "submit",
"value": "ory",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050002,
"text": "Link ory",
"type": "info",
"context": {
"provider": "ory"
}
}
}
}
]

View File

@@ -0,0 +1,92 @@
[
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "method",
"type": "submit",
"value": "oidc",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password_identifier",
"type": "text",
"value": "",
"required": true,
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "google",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "github",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "Sk3WySQDVpL5V9IuQelZTXcRVbBE1STI0HAJEcHOnM0/xPz5p1piBzuBMKZWxgYJc5TN8k8BTM9Hazrf/n76pg==",
"required": true,
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "hydra",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"disabled": false
},
"messages": null
}
]

View File

@@ -0,0 +1,100 @@
[
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "password",
"type": "password",
"required": true,
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "method",
"type": "submit",
"value": "oidc",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
"required": true,
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.email",
"type": "email",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "github",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "google",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "hydra",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.abcd",
"type": "input",
"disabled": false
},
"messages": null
}
]

View File

@@ -0,0 +1,89 @@
[
{
"type": "input",
"group": "password",
"attributes": {
"name": "method",
"type": "submit",
"value": "password",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "method",
"type": "submit",
"value": "oidc",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
"required": true,
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.email",
"type": "email",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "github",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "google",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "oidc",
"attributes": {
"name": "oidc.provider",
"type": "submit",
"value": "hydra",
"disabled": false
},
"messages": null
},
{
"type": "input",
"group": "password",
"attributes": {
"name": "traits.abcd",
"type": "input",
"disabled": false
},
"messages": null
}
]

View File

@@ -0,0 +1,155 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"required": true,
"type": "hidden",
"value": ""
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.email",
"type": "email",
"value": "john@doe.com"
},
"group": "profile",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.name",
"type": "text"
},
"group": "profile",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"type": "submit",
"value": "profile"
},
"group": "profile",
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "password",
"required": true,
"type": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"type": "submit",
"value": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "link",
"type": "submit",
"value": "github"
},
"group": "oidc",
"messages": [],
"meta": {
"label": {
"context": {
"provider": "github"
},
"id": 1050002,
"text": "Link github",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "unlink",
"type": "submit",
"value": "google"
},
"group": "oidc",
"messages": [],
"meta": {
"label": {
"context": {
"provider": "google"
},
"id": 1050003,
"text": "Unlink google",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "link",
"type": "submit",
"value": "ory"
},
"group": "oidc",
"messages": [],
"meta": {
"label": {
"context": {
"provider": "ory"
},
"id": 1050002,
"text": "Link ory",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,39 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"abcd": {
"type": "string"
},
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
},
"website": {
"type": "string",
"format": "uri",
"minLength": 10
}
},
"required": [
"email",
"website"
],
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,39 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"abcd": {
"type": "string"
},
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
},
"website": {
"type": "string",
"format": "uri",
"minLength": 10
}
},
"required": [
"email",
"website"
],
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,32 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"format": "email",
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
},
"name": {
"type": "string",
"minLength": 2
}
},
"required": [
"email"
]
}
},
"additionalProperties": false
}

14
ui/node/helper.go Normal file
View File

@@ -0,0 +1,14 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
func PasswordLoginOrder(in []string) []string {
if len(in) == 0 {
return []string{"csrf_token", "password"}
}
if len(in) == 1 {
return append([]string{"csrf_token"}, in[0], "password")
}
return append([]string{"csrf_token", in[0], "password"}, in[1:]...)
}

50
ui/node/identifiers.go Normal file
View File

@@ -0,0 +1,50 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
const (
TOTPCode = "totp_code"
TOTPSecretKey = "totp_secret_key"
TOTPQR = "totp_qr"
TOTPUnlink = "totp_unlink"
)
const (
LookupReveal = "lookup_secret_reveal"
LookupRegenerate = "lookup_secret_regenerate"
LookupDisable = "lookup_secret_disable"
LookupCodes = "lookup_secret_codes"
LookupConfirm = "lookup_secret_confirm"
LookupCodeEnter = "lookup_secret"
)
const (
ProfileChooseCredentials = "profile_choose_credentials"
)
const (
WebAuthnRegisterTrigger = "webauthn_register_trigger"
WebAuthnRegister = "webauthn_register"
WebAuthnLogin = "webauthn_login"
WebAuthnLoginTrigger = "webauthn_login_trigger"
WebAuthnRegisterDisplayName = "webauthn_register_displayname"
WebAuthnRemove = "webauthn_remove"
WebAuthnScript = "webauthn_script"
)
const (
DeviceAuthnRemove = "deviceauthn_remove"
DeviceAuthnNonce = "deviceauthn_nonce"
)
const (
PasskeyRegisterTrigger = "passkey_register_trigger"
PasskeyRegister = "passkey_register"
PasskeySettingsRegister = "passkey_settings_register"
PasskeyCreateData = "passkey_create_data"
PasskeyLogin = "passkey_login"
PasskeyChallenge = "passkey_challenge"
PasskeyLoginTrigger = "passkey_login_trigger" //#nosec G101 -- Not a credential
PasskeyRemove = "passkey_remove"
)

577
ui/node/node.go Normal file
View File

@@ -0,0 +1,577 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node
import (
"bytes"
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/text"
)
// swagger:enum UiNodeType
type UiNodeType string
const (
Text UiNodeType = "text"
Input UiNodeType = "input"
Image UiNodeType = "img"
Anchor UiNodeType = "a"
Script UiNodeType = "script"
Division UiNodeType = "div"
)
func (t UiNodeType) String() string {
return string(t)
}
// swagger:enum UiNodeGroup
type UiNodeGroup string
const (
DefaultGroup UiNodeGroup = "default"
PasswordGroup UiNodeGroup = "password"
OpenIDConnectGroup UiNodeGroup = "oidc"
ProfileGroup UiNodeGroup = "profile"
LinkGroup UiNodeGroup = "link"
CodeGroup UiNodeGroup = "code"
TOTPGroup UiNodeGroup = "totp"
LookupGroup UiNodeGroup = "lookup_secret"
WebAuthnGroup UiNodeGroup = "webauthn"
PasskeyGroup UiNodeGroup = "passkey"
IdentifierFirstGroup UiNodeGroup = "identifier_first"
CaptchaGroup UiNodeGroup = "captcha" // Available in OEL
SAMLGroup UiNodeGroup = "saml" // Available in OEL
DeviceAuthnGroup UiNodeGroup = "deviceauthn" // Available in OEL
)
func (g UiNodeGroup) String() string {
return string(g)
}
// swagger:model uiNodes
type Nodes []*Node
// Node represents a flow's nodes
//
// Nodes are represented as HTML elements or their native UI equivalents. For example,
// a node can be an `<img>` tag, or an `<input element>` but also `some plain text`.
//
// swagger:model uiNode
type Node struct {
// The node's type
//
// required: true
Type UiNodeType `json:"type" faker:"-"`
// Group specifies which group (e.g. password authenticator) this node belongs to.
//
// required: true
Group UiNodeGroup `json:"group"`
// The node's attributes.
//
// required: true
// swagger:type uiNodeAttributes
Attributes Attributes `json:"attributes" faker:"ui_node_attributes"`
// The node's messages
//
// Contains error, validation, or other messages relevant to this node.
//
// required: true
Messages text.Messages `json:"messages"`
// Meta contains a node meta information
//
// This might include a label and other information that can optionally
// be used to render UIs.
//
// required: true
Meta *Meta `json:"meta"`
}
// A Node's Meta Information
//
// This might include a label and other information that can optionally
// be used to render UIs.
//
// swagger:model uiNodeMeta
type Meta struct {
// Label represents the node's label.
//
// Keep in mind that these values are autogenerated and can not be changed.
// If you wish to use other titles or labels implement that directly in
// your UI.
Label *text.Message `json:"label,omitempty"`
}
// Used for en/decoding the Attributes field.
type jsonRawNode struct {
Type UiNodeType `json:"type"`
Group UiNodeGroup `json:"group"`
Attributes Attributes `json:"attributes"`
Messages text.Messages `json:"messages"`
Meta *Meta `json:"meta"`
}
func (n *Node) ID() string {
return n.Attributes.ID()
}
func (n *Node) Reset() {
n.Messages = nil
n.Attributes.Reset()
}
func (n *Node) WithMetaLabel(label *text.Message) *Node {
if n.Meta == nil {
n.Meta = new(Meta)
}
n.Meta.Label = label
return n
}
func (n *Node) GetValue() interface{} {
return n.Attributes.GetValue()
}
func (n Nodes) Find(id string) *Node {
for _, nn := range n {
if nn.ID() == id {
return nn
}
}
return nil
}
func (n Nodes) FindAll(id string) []Node {
var nodes []Node
for _, nn := range n {
if nn.ID() == id {
nodes = append(nodes, *nn)
}
}
return nodes
}
func (n Nodes) Reset(exclude ...string) {
for k, nn := range n {
nn.Messages = nil
if !slices.Contains(exclude, nn.ID()) {
nn.Reset()
}
n[k] = nn
}
}
func (n Nodes) ResetNodes(reset ...string) {
for k, nn := range n {
if slices.Contains(reset, nn.ID()) {
nn.Reset()
}
n[k] = nn
}
}
func (n Nodes) ResetNodesWithPrefix(prefix string) {
for k := range n {
if strings.HasPrefix(n[k].ID(), prefix) {
n[k].Reset()
}
}
}
func getStringSliceIndexOf(needle []string, haystack string) int {
for k := range needle {
if needle[k] == haystack {
return k
}
}
return -1
}
type sortOptions struct {
orderByGroups []string
schemaRef string
keysInOrder []string
keysInOrderAppend []string
keysInOrderPost func([]string) []string
stableGroups []string
}
type SortOption func(*sortOptions)
func SortByGroups(orderByGroups []UiNodeGroup) func(*sortOptions) {
return func(options *sortOptions) {
options.orderByGroups = make([]string, len(orderByGroups))
for k := range orderByGroups {
options.orderByGroups[k] = string(orderByGroups[k])
}
}
}
func SortBySchema(schemaRef string) func(*sortOptions) {
return func(options *sortOptions) {
options.schemaRef = schemaRef
}
}
func SortUseOrder(keysInOrder []string) func(*sortOptions) {
return func(options *sortOptions) {
options.keysInOrder = keysInOrder
}
}
func SortUseOrderAppend(keysInOrder []string) func(*sortOptions) {
return func(options *sortOptions) {
options.keysInOrderAppend = keysInOrder
}
}
func SortUpdateOrder(f func([]string) []string) func(*sortOptions) {
return func(options *sortOptions) {
options.keysInOrderPost = f
}
}
// SortStableGroups marks groups that arrive in a deterministic order and must
// keep it: within such a group the sort preserves insertion order instead of
// tiebreaking by value. Use it when the repeated nodes' only distinguishing
// value is opaque and non-display, so value ordering would be meaningless and unstable.
func SortStableGroups(groups ...UiNodeGroup) func(*sortOptions) {
return func(options *sortOptions) {
options.stableGroups = make([]string, len(groups))
for k := range groups {
options.stableGroups[k] = string(groups[k])
}
}
}
func (n Nodes) SortBySchema(ctx context.Context, opts ...SortOption) error {
var o sortOptions
for _, f := range opts {
f(&o)
}
if o.schemaRef != "" {
schemaKeys, err := schema.GetKeysInOrder(ctx, o.schemaRef)
if err != nil {
return err
}
o.keysInOrder = append(o.keysInOrder, schemaKeys...)
}
if o.keysInOrderPost != nil {
o.keysInOrder = o.keysInOrderPost(o.keysInOrder)
}
o.keysInOrder = append(o.keysInOrder, o.keysInOrderAppend...)
getKeyPosition := func(node *Node) int {
lastPrefix := len(o.keysInOrder)
// Method should always be the last element in the list
if node.Attributes.ID() == "method" {
return len(n) + len(o.keysInOrder) + 1
}
for i, n := range o.keysInOrder {
if strings.HasPrefix(node.ID(), n) {
return i
}
}
return lastPrefix
}
if len(o.orderByGroups) > 0 {
// Sort by groups so that default is in front, then oidc, password, ...
// A stable sort keeps same-group nodes in their incoming order, so a
// group listed in stableGroups is not reshuffled before the tiebreak
// below has a chance to preserve it.
sort.SliceStable(n, func(i, j int) bool {
a := string(n[i].Group)
b := string(n[j].Group)
return getStringSliceIndexOf(o.orderByGroups, a) < getStringSliceIndexOf(o.orderByGroups, b)
})
}
sort.SliceStable(n, func(i, j int) bool {
a := n[i]
b := n[j]
if a.Group == b.Group ||
(a.Group == "default" && b.Group == "password") || (b.Group == "default" && a.Group == "password") {
pa, pb := getKeyPosition(a), getKeyPosition(b)
if pa < pb {
return true
} else if pa > pb {
return false
}
// A stable group provides its own deterministic order; keep it
// rather than tiebreaking by the (meaningless, possibly unstable)
// value.
if a.Group == b.Group && slices.Contains(o.stableGroups, string(a.Group)) {
return false
}
return fmt.Sprintf("%v", a.GetValue()) < fmt.Sprintf("%v", b.GetValue())
}
return false
})
return nil
}
// Remove removes one or more nodes by their IDs.
func (n *Nodes) Remove(ids ...string) {
if n == nil {
return
}
var r Nodes
for k, v := range *n {
var found bool
for _, needle := range ids {
if (*n)[k].ID() == needle {
found = true
break
}
}
if !found {
r = append(r, v)
}
}
*n = r
}
// RemoveInGroup removes nodes whose ID is one of ids and that belong to the
// given group. Unlike Remove, which matches on ID alone, this leaves nodes with
// the same ID in other groups untouched — so one strategy clearing its own
// link/unlink nodes does not clobber another strategy's nodes that share the
// "link"/"unlink" input name but render in a different group.
func (n *Nodes) RemoveInGroup(group UiNodeGroup, ids ...string) {
if n == nil {
return
}
var r Nodes
for _, v := range *n {
if v != nil && v.Group == group && slices.Contains(ids, v.ID()) {
continue
}
r = append(r, v)
}
*n = r
}
// RemoveGroup removes all nodes belonging to a specific group.
func (n *Nodes) RemoveGroup(group UiNodeGroup) {
if n == nil {
return
}
var r Nodes
for k, v := range *n {
if (*n)[k] != nil && string((*n)[k].Group) != string(group) {
r = append(r, v)
}
}
*n = r
}
// RemoveGroup removes all nodes belonging to a set of groups.
func (n *Nodes) RemoveGroups(group ...UiNodeGroup) {
if n == nil {
return
}
for _, g := range group {
n.RemoveGroup(g)
}
}
// ClearTransientNodes removes all nodes that belong to transient groups.
func (n *Nodes) ClearTransientNodes() {
n.RemoveGroups(
DefaultGroup,
PasswordGroup,
OpenIDConnectGroup,
ProfileGroup,
LinkGroup,
CodeGroup,
TOTPGroup,
LookupGroup,
WebAuthnGroup,
PasskeyGroup,
IdentifierFirstGroup,
SAMLGroup,
)
}
// Upsert updates or appends a node.
func (n *Nodes) Upsert(node *Node) {
if n == nil {
*n = append(*n, node)
return
}
for i := range *n {
if (*n)[i].ID() == node.ID() {
(*n)[i] = node
return
}
}
*n = append(*n, node)
}
// SetValueAttribute sets a node's attribute's value or returns false if no node is found.
func (n *Nodes) SetValueAttribute(id string, value interface{}) bool {
for i := range *n {
if (*n)[i].ID() == id {
(*n)[i].Attributes.SetValue(value)
return true
}
}
return false
}
// Append appends a node.
func (n *Nodes) Append(node *Node) {
*n = append(*n, node)
}
func (n *Nodes) RemoveMatching(node *Node) {
if n == nil {
return
}
r := Nodes{}
for k, v := range *n {
if !(*n)[k].Matches(node) {
r = append(r, v)
}
}
*n = r
}
func (n *Node) Matches(needle *Node) bool {
if len(needle.ID()) > 0 && n.ID() != needle.ID() {
return false
}
if needle.Type != "" && n.Type != needle.Type {
return false
}
if needle.Group != "" && n.Group != needle.Group {
return false
}
return n.Attributes.Matches(needle.Attributes)
}
func (n *Node) UnmarshalJSON(data []byte) error {
var attr Attributes
switch t := gjson.GetBytes(data, "type").String(); UiNodeType(t) {
case Text:
attr = &TextAttributes{
NodeType: Text,
}
case Input:
attr = &InputAttributes{
NodeType: Input,
}
case Anchor:
attr = &AnchorAttributes{
NodeType: Anchor,
}
case Image:
attr = &ImageAttributes{
NodeType: Image,
}
case Script:
attr = &ScriptAttributes{
NodeType: Script,
}
case Division:
attr = &DivisionAttributes{
NodeType: Division,
}
default:
return fmt.Errorf("unexpected node type: %s", t)
}
var d jsonRawNode
d.Attributes = attr
if err := json.NewDecoder(bytes.NewReader(data)).Decode(&d); err != nil {
return err
}
*n = Node(d)
if n.Meta == nil {
n.Meta = new(Meta)
}
return nil
}
func (n *Node) MarshalJSON() ([]byte, error) {
var t UiNodeType
if n.Attributes != nil {
switch attr := n.Attributes.(type) {
case *TextAttributes:
t = Text
attr.NodeType = Text
case *InputAttributes:
t = Input
attr.NodeType = Input
case *AnchorAttributes:
t = Anchor
attr.NodeType = Anchor
case *ImageAttributes:
t = Image
attr.NodeType = Image
case *ScriptAttributes:
t = Script
attr.NodeType = Script
case *DivisionAttributes:
t = Division
attr.NodeType = Division
default:
return nil, errors.WithStack(fmt.Errorf("unknown node type: %T", n.Attributes))
}
}
if n.Type == "" {
n.Type = t
} else if n.Type != t {
return nil, errors.WithStack(fmt.Errorf("node type and node attributes mismatch: %T != %s", n.Attributes, n.Type))
}
if n.Meta == nil {
n.Meta = new(Meta)
}
return json.Marshal((*jsonRawNode)(n))
}

560
ui/node/node_test.go Normal file
View File

@@ -0,0 +1,560 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package node_test
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"path/filepath"
"testing"
"github.com/ory/x/snapshotx"
"github.com/ory/kratos/text"
"github.com/ory/x/assertx"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/ui/container"
"github.com/go-faker/faker/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/ui/node"
)
func init() {
corpx.RegisterFakes()
}
//go:embed fixtures/sort/*
var sortFixtures embed.FS
var ctx = context.Background()
func TestNodesSort(t *testing.T) {
// use a schema compiler that disables identifiers
schemaCompiler := jsonschema.NewCompiler()
schemaPath := "fixtures/identity.schema.json"
f, err := container.NewFromJSONSchema(ctx, "/foo", node.DefaultGroup, schemaPath, "", schemaCompiler, false)
require.NoError(t, err)
f.UpdateNodeValuesFromJSON(json.RawMessage(`{}`), "traits", node.DefaultGroup)
f.SetCSRF("csrf_token")
inputs, err := sortFixtures.ReadDir("fixtures/sort/input")
require.NoError(t, err)
options := map[string][]node.SortOption{
"1.json": {
node.SortUseOrder([]string{"password_identifier"}),
node.SortUpdateOrder(node.PasswordLoginOrder),
node.SortByGroups([]node.UiNodeGroup{
node.DefaultGroup,
node.ProfileGroup,
node.OpenIDConnectGroup,
node.PasswordGroup,
node.LinkGroup,
node.LinkGroup,
}),
},
"2.json": {
node.SortBySchema(filepath.Join("fixtures/sort/schema", "2.json")),
node.SortUpdateOrder(node.PasswordLoginOrder),
node.SortByGroups([]node.UiNodeGroup{
node.DefaultGroup,
node.OpenIDConnectGroup,
node.PasswordGroup,
}),
},
"3.json": {
node.SortBySchema(filepath.Join("fixtures/sort/schema", "3.json")),
node.SortByGroups([]node.UiNodeGroup{
node.DefaultGroup,
node.OpenIDConnectGroup,
node.PasswordGroup,
}),
},
"4.json": {
node.SortBySchema(filepath.Join("fixtures/sort/schema", "4.json")),
node.SortByGroups([]node.UiNodeGroup{
node.DefaultGroup,
node.ProfileGroup,
node.PasswordGroup,
node.OpenIDConnectGroup,
node.LookupGroup,
node.WebAuthnGroup,
node.TOTPGroup,
}),
node.SortUseOrderAppend([]string{
// Lookup
node.LookupReveal,
node.LookupRegenerate,
node.LookupCodes,
node.LookupConfirm,
// Lookup
node.WebAuthnRemove,
node.WebAuthnRegisterDisplayName,
node.WebAuthnRegister,
// TOTP
node.TOTPSecretKey,
node.TOTPQR,
node.TOTPUnlink,
node.TOTPCode,
}),
},
}
for _, in := range inputs {
t.Run("file="+in.Name(), func(t *testing.T) {
if in.IsDir() {
return
}
fi, err := sortFixtures.Open(filepath.Join("fixtures/sort/input", in.Name()))
require.NoError(t, err)
defer func() { _ = fi.Close() }()
var nodes node.Nodes
require.NoError(t, json.NewDecoder(fi).Decode(&nodes))
require.NotEmpty(t, nodes)
require.NoError(t, nodes.SortBySchema(ctx, options[in.Name()]...))
fe, err := sortFixtures.ReadFile(filepath.Join("fixtures/sort/expected", in.Name()))
require.NoError(t, err)
assertx.EqualAsJSON(t, json.RawMessage(fe), nodes)
})
}
}
func TestNodesSortStableGroups(t *testing.T) {
t.Parallel()
// Build count nodes in the same group, sharing an input name (as a list of
// remove buttons does). Insertion order is 0..count-1, recorded in each
// node's label; the value is the reverse, so a value tiebreak would flip the
// order. count exceeds Go's insertion-sort threshold (12) so the sort takes
// the quicksort path, where an unstable sort could otherwise reshuffle
// same-group nodes.
const count = 20
build := func() node.Nodes {
var n node.Nodes
for i := range count {
value := fmt.Sprintf("%02d", count-1-i)
n = append(n, node.NewInputField(node.DeviceAuthnRemove, value, node.DeviceAuthnGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(&text.Message{Text: fmt.Sprintf("%02d", i)}))
}
return n
}
labels := func(n node.Nodes) []string {
got := make([]string, 0, len(n))
for _, nn := range n {
got = append(got, nn.Meta.Label.Text)
}
return got
}
insertionOrder := make([]string, count)
valueOrder := make([]string, count)
for i := range count {
insertionOrder[i] = fmt.Sprintf("%02d", i)
valueOrder[i] = fmt.Sprintf("%02d", count-1-i)
}
t.Run("case=stable group preserves insertion order", func(t *testing.T) {
t.Parallel()
n := build()
require.NoError(t, n.SortBySchema(ctx,
node.SortByGroups([]node.UiNodeGroup{node.DefaultGroup, node.PasswordGroup}),
node.SortStableGroups(node.DeviceAuthnGroup),
))
assert.Equal(t, insertionOrder, labels(n))
})
t.Run("case=without the option the value tiebreak reorders", func(t *testing.T) {
t.Parallel()
n := build()
require.NoError(t, n.SortBySchema(ctx,
node.SortByGroups([]node.UiNodeGroup{node.DefaultGroup, node.PasswordGroup}),
))
assert.Equal(t, valueOrder, labels(n))
})
}
func TestNodesUpsert(t *testing.T) {
var nodes node.Nodes
nodes.Upsert(node.NewCSRFNode("foo"))
require.Len(t, nodes, 1)
nodes.Upsert(node.NewCSRFNode("bar"))
require.Len(t, nodes, 1)
assert.EqualValues(t, "bar", nodes[0].Attributes.GetValue())
}
func TestNodesRemove(t *testing.T) {
var nodes node.Nodes
nodes.Append(node.NewInputField("other", "foo", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
nodes.Append(node.NewInputField("link", "foo", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
nodes.Append(node.NewInputField("link", "bar", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
nodes.Append(node.NewInputField("unlink", "baz", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
require.Len(t, nodes, 4)
nodes.Remove("link", "unlink")
require.Len(t, nodes, 1)
}
func TestNodesRemoveInGroup(t *testing.T) {
samlLink := node.NewInputField("link", "saml-provider", node.SAMLGroup, node.InputAttributeTypeSubmit)
nodes := node.Nodes{
node.NewInputField("link", "oidc-provider", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit),
samlLink,
node.NewInputField("unlink", "oidc-provider", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit),
node.NewCSRFNode("token"),
}
// Remove only the OIDC group's link/unlink nodes.
nodes.RemoveInGroup(node.OpenIDConnectGroup, "link", "unlink")
require.Len(t, nodes, 2)
// The SAML link node shares the "link" name but a different group: it survives.
assert.Contains(t, nodes, samlLink)
// The CSRF node (DefaultGroup) is untouched.
var sawCSRF bool
for _, n := range nodes {
if n.Group == node.DefaultGroup {
sawCSRF = true
}
}
assert.True(t, sawCSRF, "CSRF node must not be removed")
}
func TestNodeJSON(t *testing.T) {
t.Run("idempotent decode", func(t *testing.T) {
nodes := make(node.Nodes, 5)
for k := range nodes {
nodes[k] = new(node.Node)
expected := nodes[k]
require.NoError(t, faker.FakeData(expected))
var b bytes.Buffer
require.NoError(t, json.NewEncoder(&b).Encode(expected))
var actual node.Node
require.NoError(t, json.NewDecoder(&b).Decode(&actual))
assert.EqualValues(t, *expected, actual)
assert.NotEmpty(t, actual.Type)
assert.NotNil(t, actual.Attributes)
}
})
t.Run("type mismatch", func(t *testing.T) {
n := &node.Node{Type: node.Image, Attributes: new(node.InputAttributes)}
var b bytes.Buffer
require.EqualError(t, json.NewEncoder(&b).Encode(n), "json: error calling MarshalJSON for type *node.Node: node type and node attributes mismatch: *node.InputAttributes != img")
})
t.Run("type empty", func(t *testing.T) {
n := &node.Node{Attributes: new(node.InputAttributes)}
var b bytes.Buffer
require.NoError(t, json.NewEncoder(&b).Encode(n))
assert.EqualValues(t, node.Input, gjson.GetBytes(b.Bytes(), "type").String())
})
t.Run("type decode unknown", func(t *testing.T) {
var n node.Node
require.EqualError(t, json.NewDecoder(bytes.NewReader(json.RawMessage(`{"type": "foo"}`))).Decode(&n), "unexpected node type: foo")
})
}
func TestMatchesNode(t *testing.T) {
// Test when ID is different
node1 := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
node2 := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}}
assert.False(t, node1.Matches(node2))
// Test when Type is different
node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
node2 = &node.Node{Type: node.Text, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
assert.False(t, node1.Matches(node2))
// Test when Group is different
node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
node2 = &node.Node{Type: node.Input, Group: node.OpenIDConnectGroup, Attributes: &node.InputAttributes{Name: "foo"}}
assert.False(t, node1.Matches(node2))
// Test when all fields are the same
node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
node2 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
assert.True(t, node1.Matches(node2))
}
func TestRemoveMatchingNodes(t *testing.T) {
nodes := node.Nodes{
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}},
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}},
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "baz"}},
}
// Test when node to remove is present
nodeToRemove := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}}
nodes.RemoveMatching(nodeToRemove)
assert.Len(t, nodes, 2)
for _, n := range nodes {
assert.NotEqual(t, nodeToRemove.ID(), n.ID())
}
// Test when node to remove is not present
nodeToRemove = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "qux"}}
nodes.RemoveMatching(nodeToRemove)
assert.Len(t, nodes, 2) // length should remain the same
// Test when node to remove is present
nodeToRemove = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "baz"}}
ui := &container.Container{
Nodes: nodes,
}
ui.GetNodes().RemoveMatching(nodeToRemove)
assert.Len(t, *ui.GetNodes(), 1)
for _, n := range *ui.GetNodes() {
assert.NotEqual(t, "bar", n.ID())
assert.NotEqual(t, "baz", n.ID())
}
ui.Nodes.Append(node.NewInputField("method", "foo", "bar", node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue()))
assert.NotNil(t, ui.Nodes.Find("method"))
ui.GetNodes().RemoveMatching(node.NewInputField("method", "foo", "bar", node.InputAttributeTypeSubmit))
assert.Nil(t, ui.Nodes.Find("method"))
}
func TestNodeMarshalJSON(t *testing.T) {
tests := []struct {
name string
node *node.Node
wantErr bool
errMsg string
}{
{
name: "text node",
node: &node.Node{
Type: node.Text,
Group: node.DefaultGroup,
Attributes: &node.TextAttributes{
NodeType: node.Text,
},
Messages: text.Messages{},
Meta: &node.Meta{},
},
},
{
name: "input node",
node: &node.Node{
Type: node.Input,
Group: node.DefaultGroup,
Attributes: &node.InputAttributes{
NodeType: node.Input,
Name: "password",
Type: "password",
FieldValue: "secret",
},
Messages: text.Messages{},
Meta: &node.Meta{},
},
},
{
name: "anchor node",
node: &node.Node{
Type: node.Anchor,
Group: node.DefaultGroup,
Attributes: &node.AnchorAttributes{
NodeType: node.Anchor,
HREF: "https://example.com",
},
Messages: text.Messages{},
Meta: &node.Meta{},
},
},
{
name: "image node",
node: &node.Node{
Type: node.Image,
Group: node.DefaultGroup,
Attributes: &node.ImageAttributes{
NodeType: node.Image,
Source: "image.jpg",
},
Messages: text.Messages{},
Meta: &node.Meta{},
},
},
{
name: "script node",
node: &node.Node{
Type: node.Script,
Group: node.DefaultGroup,
Attributes: &node.ScriptAttributes{
NodeType: node.Script,
Source: "script.js",
},
Messages: text.Messages{},
Meta: &node.Meta{},
},
},
{
name: "division node",
node: &node.Node{
Type: node.Division,
Group: node.DefaultGroup,
Attributes: &node.DivisionAttributes{
NodeType: node.Division,
},
Messages: text.Messages{},
Meta: &node.Meta{},
},
},
{
name: "type mismatch",
node: &node.Node{
Type: node.Image,
Group: node.DefaultGroup,
Attributes: &node.InputAttributes{NodeType: node.Input},
},
wantErr: true,
errMsg: "node type and node attributes mismatch",
},
{
name: "empty type inferred from attributes",
node: &node.Node{
Group: node.DefaultGroup,
Attributes: &node.InputAttributes{
NodeType: node.Input,
Name: "email",
},
Messages: text.Messages{},
Meta: &node.Meta{},
},
},
{
name: "nil attributes",
node: &node.Node{
Type: node.Image,
Group: node.DefaultGroup,
Attributes: nil,
Messages: text.Messages{},
},
wantErr: true,
errMsg: "node type and node attributes mismatch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(tt.node)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
return
}
require.NoError(t, err)
// Use snapshotx for testing serialization
snapshotx.SnapshotT(t, json.RawMessage(data))
// Verify roundtrip
var unmarshalled node.Node
err = json.Unmarshal(data, &unmarshalled)
require.NoError(t, err)
// Re-marshal for comparison
remarshalled, err := json.Marshal(&unmarshalled)
require.NoError(t, err)
assert.JSONEq(t, string(data), string(remarshalled))
})
}
}
func TestNodes_FindAll(t *testing.T) {
nodes := node.Nodes{
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "a"}},
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "b"}},
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "a"}},
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "c"}},
}
t.Run("find matches multiple nodes", func(t *testing.T) {
got := nodes.FindAll("a")
assert.Equal(t, []node.Node{*nodes[0], *nodes[2]}, got)
})
t.Run("find matches one node", func(t *testing.T) {
got := nodes.FindAll("b")
assert.Equal(t, []node.Node{*nodes[1]}, got)
})
t.Run("no match returns empty slice", func(t *testing.T) {
got := nodes.FindAll("x")
assert.Empty(t, got)
})
}
func TestNodes_RemoveGroup(t *testing.T) {
nodes := node.Nodes{
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "default"}},
&node.Node{Type: node.Input, Group: node.CaptchaGroup, Attributes: &node.InputAttributes{Name: "captcha1"}},
&node.Node{Type: node.Input, Group: node.IdentifierFirstGroup, Attributes: &node.InputAttributes{Name: "idfirst"}},
&node.Node{Type: node.Input, Group: node.CaptchaGroup, Attributes: &node.InputAttributes{Name: "captcha2"}},
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "password"}},
nil,
}
t.Run("removes single node", func(t *testing.T) {
n := append(node.Nodes(nil), nodes...)
n.RemoveGroup(node.IdentifierFirstGroup)
assert.Equal(t, node.Nodes{nodes[0], nodes[1], nodes[3], nodes[4]}, n)
})
t.Run("removes multiple nodes", func(t *testing.T) {
n := append(node.Nodes(nil), nodes...)
n.RemoveGroup(node.CaptchaGroup)
assert.Equal(t, node.Nodes{nodes[0], nodes[2], nodes[4]}, n)
})
t.Run("removes no nodes", func(t *testing.T) {
n := append(node.Nodes(nil), nodes...)
n.RemoveGroup(node.PasskeyGroup)
assert.Equal(t, nodes[:5], n)
})
t.Run("removes multiple groups", func(t *testing.T) {
n := append(node.Nodes(nil), nodes...)
n.RemoveGroups(node.CaptchaGroup, node.PasswordGroup)
assert.Equal(t, node.Nodes{nodes[0], nodes[2]}, n)
})
t.Run("removes multiple groups empty", func(t *testing.T) {
n := append(node.Nodes(nil), nodes...)
n.RemoveGroups()
assert.Equal(t, nodes, n)
})
t.Run("removes multiple groups no matching", func(t *testing.T) {
n := append(node.Nodes(nil), nodes...)
n.RemoveGroups(node.PasskeyGroup)
assert.Equal(t, nodes[:5], n)
})
}