snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
117
server/compose/service/values/expr.go
Normal file
117
server/compose/service/values/expr.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/modern-go/reflect2"
|
||||
)
|
||||
|
||||
func makeInvalidExprErr(field *types.ModuleField, expr string, err error) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "valueExpression",
|
||||
Message: fmt.Sprintf("invalid expression %q: %v", expr, err.Error()),
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
func makeExprEvalErr(field *types.ModuleField, expr string, err error) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "valueExpression",
|
||||
Message: fmt.Sprintf("failed to evaluate formula expression %q: %v", expr, err.Error()),
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
|
||||
func makeValueExprIncompErr(field *types.ModuleField) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "evaluatedValueIncompatible",
|
||||
Message: "evaluated results incompatible",
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
|
||||
// Expression evaluates expression in ModuleField.Expressions.Value and
|
||||
// assigns results to the record on that field
|
||||
func Expression(ctx context.Context, m *types.Module, r *types.Record, old *types.Record, rve *types.RecordValueErrorSet) {
|
||||
var (
|
||||
exprParser = expr.Parser()
|
||||
|
||||
scope = make(map[string]interface{})
|
||||
|
||||
reserved = map[string]bool{
|
||||
"new": true,
|
||||
"old": true,
|
||||
}
|
||||
)
|
||||
|
||||
// base scope with field=value(s) from new record
|
||||
scope = r.Values.Dict(m.Fields)
|
||||
|
||||
// new record
|
||||
r.SetModule(m)
|
||||
scope["new"] = r.Dict()
|
||||
|
||||
if old != nil {
|
||||
// old values on record (before update)
|
||||
// this will not be set for new records
|
||||
old.SetModule(m)
|
||||
scope["old"] = old.Dict()
|
||||
}
|
||||
|
||||
for _, f := range m.Fields {
|
||||
if f.Expressions.ValueExpr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
expr := f.Expressions.ValueExpr
|
||||
|
||||
eval, err := exprParser.NewEvaluable(expr)
|
||||
if err != nil {
|
||||
rve.Push(makeInvalidExprErr(f, expr, err))
|
||||
return
|
||||
}
|
||||
|
||||
tmp, err := eval(ctx, scope)
|
||||
if err != nil {
|
||||
rve.Push(makeExprEvalErr(f, expr, err))
|
||||
return
|
||||
}
|
||||
|
||||
var strings []string
|
||||
// When the expression results in null (nil) delete that record value.
|
||||
// The below Values.Replace already does it so we simply ignore strings
|
||||
if !reflect2.IsNil(tmp) {
|
||||
if values, isSlice := tmp.([]interface{}); isSlice {
|
||||
if !f.Multi {
|
||||
rve.Push(makeValueExprIncompErr(f))
|
||||
continue
|
||||
}
|
||||
|
||||
strings = make([]string, len(values))
|
||||
for i, value := range values {
|
||||
strings[i] = sanitize(f, value)
|
||||
}
|
||||
} else {
|
||||
if f.Multi {
|
||||
rve.Push(makeValueExprIncompErr(f))
|
||||
continue
|
||||
}
|
||||
|
||||
strings = []string{sanitize(f, tmp)}
|
||||
}
|
||||
}
|
||||
|
||||
r.Values = r.Values.Replace(f.Name, strings...)
|
||||
|
||||
if !reserved[f.Name] {
|
||||
// make sure we do not overrider reserved fields
|
||||
scope[f.Name] = tmp
|
||||
}
|
||||
|
||||
// Reset $new with updated data
|
||||
r.SetModule(m)
|
||||
scope["new"] = r.Dict()
|
||||
}
|
||||
}
|
||||
179
server/compose/service/values/expr_test.go
Normal file
179
server/compose/service/values/expr_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExpressions(t *testing.T) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
|
||||
// pairs = [<fieldname>, <value expression>, ...]
|
||||
makeModule = func(pairs ...string) *types.Module {
|
||||
var (
|
||||
m = &types.Module{}
|
||||
)
|
||||
|
||||
for i := 0; i < len(pairs); i += 3 {
|
||||
f := &types.ModuleField{Name: pairs[i], Kind: pairs[i+1], Options: map[string]interface{}{}}
|
||||
f.Expressions.ValueExpr = pairs[i+2]
|
||||
m.Fields = append(m.Fields, f)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
)
|
||||
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = &types.Module{}
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.True(rve.IsValid())
|
||||
})
|
||||
|
||||
t.Run("string", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `"abc"`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("abc", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("use fields", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `fname + " " + lname`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "fname", Kind: "String"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "lname", Kind: "String"})
|
||||
r.Values = r.Values.Replace("fname", "Cor")
|
||||
r.Values = r.Values.Replace("lname", "Teza")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("Cor Teza", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("math", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "Number", `n1 * n2 * 1.511`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields.FindByName("f1").Options.SetPrecision(1)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "n1", Kind: "Number"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "n2", Kind: "Number"})
|
||||
r.Values = r.Values.Replace("n1", "10")
|
||||
r.Values = r.Values.Replace("n2", "20")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("302.2", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("booleans", func(t *testing.T) {
|
||||
var (
|
||||
m = makeModule("f1", "string", `b1 ? "yes" : "no"`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "b1", Kind: "Bool"})
|
||||
|
||||
t.Run("true", func(t *testing.T) {
|
||||
var req = require.New(t)
|
||||
r.Values = r.Values.Replace("b1", "true")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("yes", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("false", func(t *testing.T) {
|
||||
var req = require.New(t)
|
||||
r.Values = r.Values.Replace("b1", "false")
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("no", r.Values.Get("f1", 0).Value)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("old vs new", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `new.values.test == old.values.test ? "same":"different"`)
|
||||
new = &types.Record{}
|
||||
old = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "test", Kind: "String"})
|
||||
|
||||
new.Values = new.Values.Replace("test", "a")
|
||||
old.Values = old.Values.Replace("test", "a")
|
||||
|
||||
Expression(ctx, m, new, old, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("same", new.Values.Get("f1", 0).Value)
|
||||
})
|
||||
|
||||
t.Run("multi value expressions", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `[t1,t2,t3]`)
|
||||
new = &types.Record{}
|
||||
old = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
m.Fields.FindByName("f1").Multi = true
|
||||
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "t1", Kind: "String"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "t2", Kind: "String"})
|
||||
m.Fields = append(m.Fields, &types.ModuleField{Name: "t3", Kind: "String"})
|
||||
|
||||
new.Values = new.Values.Replace("t1", "a")
|
||||
new.Values = new.Values.Replace("t2", "b")
|
||||
new.Values = new.Values.Replace("t3", "c")
|
||||
|
||||
Expression(ctx, m, new, old, rve)
|
||||
req.Truef(rve.IsValid(), "%v", rve.Set)
|
||||
req.Equal("a", new.Values.Get("f1", 0).Value)
|
||||
req.Equal("b", new.Values.Get("f1", 1).Value)
|
||||
req.Equal("c", new.Values.Get("f1", 2).Value)
|
||||
})
|
||||
|
||||
t.Run("omit record value when expr returns null (nil)", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
m = makeModule("f1", "String", `null`)
|
||||
r = &types.Record{}
|
||||
rve = &types.RecordValueErrorSet{}
|
||||
)
|
||||
|
||||
Expression(ctx, m, r, nil, rve)
|
||||
req.True(rve.IsValid())
|
||||
req.Empty(r.Values)
|
||||
})
|
||||
}
|
||||
126
server/compose/service/values/formatter.go
Normal file
126
server/compose/service/values/formatter.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
formatter struct{}
|
||||
)
|
||||
|
||||
// Formatter initializes formatter
|
||||
//
|
||||
// Not really needed, following pattern in the package
|
||||
func Formatter() *formatter {
|
||||
return &formatter{}
|
||||
}
|
||||
|
||||
func (f formatter) Run(m *types.Module, vv types.RecordValueSet) types.RecordValueSet {
|
||||
var (
|
||||
exprParser = expr.Parser()
|
||||
|
||||
log = logger.Default().
|
||||
With(logger.Uint64("module", m.ID))
|
||||
)
|
||||
|
||||
for _, v := range vv {
|
||||
fld := m.Fields.FindByName(v.Name)
|
||||
if fld == nil {
|
||||
// Unknown field,
|
||||
// if it is not handled before,
|
||||
// formatter does not care about it
|
||||
continue
|
||||
}
|
||||
|
||||
if fld.Expressions.ValueExpr != "" {
|
||||
// do not do any validation if field has value expression!
|
||||
continue
|
||||
}
|
||||
|
||||
if !(fld.Expressions.DisableDefaultFormatters && len(fld.Expressions.Formatters) > 0) {
|
||||
// Per field type validators
|
||||
switch strings.ToLower(fld.Kind) {
|
||||
case "bool":
|
||||
v = f.fBool(v)
|
||||
case "datetime":
|
||||
v = f.fDatetime(v, fld)
|
||||
}
|
||||
}
|
||||
|
||||
if len(fld.Expressions.Formatters) > 0 {
|
||||
for _, expr := range fld.Expressions.Formatters {
|
||||
rval, err := exprParser.Evaluate(expr, map[string]interface{}{"value": v.Value})
|
||||
if err != nil {
|
||||
log.Error(
|
||||
"failed to evaluate sanitizer expression",
|
||||
zap.String("field", fld.Name),
|
||||
zap.String("expr", expr),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
v.Value = sanitize(fld, rval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vv
|
||||
}
|
||||
|
||||
// Boolean values are outputed as "1" (true) and "" (false)
|
||||
func (formatter) fBool(v *types.RecordValue) *types.RecordValue {
|
||||
if v.Value != strBoolTrue {
|
||||
v.Value = ""
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (formatter) fDatetime(v *types.RecordValue, f *types.ModuleField) *types.RecordValue {
|
||||
var (
|
||||
// database formats
|
||||
dbFormats []string
|
||||
|
||||
// output format
|
||||
internalFormat string
|
||||
)
|
||||
|
||||
if f.Options.Bool("onlyDate") {
|
||||
internalFormat = datetimeInternalFormatDate
|
||||
dbFormats = []string{
|
||||
datetimeInternalFormatDate,
|
||||
}
|
||||
} else if f.Options.Bool("onlyTime") {
|
||||
internalFormat = datetimeIntenralFormatTime
|
||||
dbFormats = []string{
|
||||
datetimeIntenralFormatTime,
|
||||
"15:04",
|
||||
}
|
||||
} else {
|
||||
internalFormat = datetimeInternalFormatFull
|
||||
// date & time
|
||||
dbFormats = []string{
|
||||
datetimeInternalFormatFull,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range dbFormats {
|
||||
parsed, err := time.Parse(format, v.Value)
|
||||
if err == nil {
|
||||
v.Value = parsed.UTC().Format(internalFormat)
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
v.Value = ""
|
||||
return v
|
||||
|
||||
}
|
||||
285
server/compose/service/values/sanitizer.go
Normal file
285
server/compose/service/values/sanitizer.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/xss"
|
||||
"github.com/spf13/cast"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
)
|
||||
|
||||
type (
|
||||
sanitizer struct{}
|
||||
)
|
||||
|
||||
// Sanitizer initializes sanitizer
|
||||
//
|
||||
// Not really needed, following pattern in the package
|
||||
func Sanitizer() *sanitizer {
|
||||
return &sanitizer{}
|
||||
}
|
||||
|
||||
// Run cleans up input data
|
||||
// - fix multi-value order/place index
|
||||
// - trim all the strings!
|
||||
// - parse & format input values to match field specific -- nullify/falsify invalid
|
||||
// - field kind specific, no errors raised, data is modified
|
||||
//
|
||||
// Existing data (when updating record) is not yet loaded at this point
|
||||
func (s sanitizer) Run(m *types.Module, vv types.RecordValueSet) (out types.RecordValueSet) {
|
||||
var (
|
||||
exprParser = expr.Parser()
|
||||
)
|
||||
|
||||
out = make([]*types.RecordValue, 0, len(vv))
|
||||
|
||||
for _, f := range m.Fields {
|
||||
// Reorder and sanitize place value (no gaps)
|
||||
//
|
||||
// Values are ordered when received so we treat them like it
|
||||
// and assign the appropriate place no.
|
||||
var i = 0
|
||||
for _, v := range vv.FilterByName(f.Name) {
|
||||
if v.IsDeleted() {
|
||||
continue
|
||||
}
|
||||
|
||||
c := v.Clone()
|
||||
c.Place = uint(i)
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
f *types.ModuleField
|
||||
kind string
|
||||
|
||||
log = logger.Default().
|
||||
With(logger.Uint64("module", m.ID))
|
||||
)
|
||||
|
||||
for _, v := range out {
|
||||
f = m.Fields.FindByName(v.Name)
|
||||
if f == nil {
|
||||
// Unknown field,
|
||||
// if it is not handled before,
|
||||
// sanitizer does not care about it
|
||||
continue
|
||||
}
|
||||
|
||||
if f.Expressions.ValueExpr != "" {
|
||||
// do not do any sanitization if field has value expression!
|
||||
continue
|
||||
}
|
||||
|
||||
if v.IsDeleted() || !v.Updated {
|
||||
// Ignore unchanged and deleted
|
||||
continue
|
||||
}
|
||||
|
||||
kind = strings.ToLower(f.Kind)
|
||||
|
||||
if len(f.Expressions.Sanitizers) > 0 {
|
||||
for _, expr := range f.Expressions.Sanitizers {
|
||||
rval, err := exprParser.Evaluate(expr, map[string]interface{}{"value": v.Value})
|
||||
if err != nil {
|
||||
log.Error(
|
||||
"failed to evaluate sanitizer expression",
|
||||
zap.String("field", f.Name),
|
||||
zap.String("expr", expr),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
v.Value = sanitize(f, rval)
|
||||
}
|
||||
}
|
||||
|
||||
if kind != "string" {
|
||||
// Trim all but string
|
||||
v.Value = strings.TrimSpace(v.Value)
|
||||
}
|
||||
|
||||
if f.IsRef() {
|
||||
if refy.MatchString(v.Value) {
|
||||
v.Ref, _ = strconv.ParseUint(v.Value, 10, 64)
|
||||
}
|
||||
|
||||
if v.Ref == 0 {
|
||||
v.Value = ""
|
||||
}
|
||||
}
|
||||
|
||||
v.Value = sanitize(f, v.Value)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s sanitizer) RunXSS(m *types.Module, vv types.RecordValueSet) types.RecordValueSet {
|
||||
var (
|
||||
f *types.ModuleField
|
||||
)
|
||||
|
||||
for _, v := range vv {
|
||||
f = m.Fields.FindByName(v.Name)
|
||||
if f == nil {
|
||||
// Unknown field,
|
||||
// if it is not handled before,
|
||||
// sanitizer does not care about it
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(f.Kind) {
|
||||
case "string":
|
||||
v.Value = sString(v.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return vv
|
||||
}
|
||||
|
||||
func sBool(v interface{}) string {
|
||||
switch c := v.(type) {
|
||||
case bool:
|
||||
if c {
|
||||
return strBoolTrue
|
||||
}
|
||||
|
||||
case string:
|
||||
if truthy.MatchString(strings.ToLower(c)) {
|
||||
return strBoolTrue
|
||||
}
|
||||
}
|
||||
|
||||
// Returning empty string here to align false value with everything else
|
||||
return strBoolFalseAlt
|
||||
}
|
||||
|
||||
func sDatetime(v interface{}, onlyDate, onlyTime bool) string {
|
||||
var (
|
||||
// input format set
|
||||
inputFormats []string
|
||||
|
||||
// output format
|
||||
internalFormat string
|
||||
|
||||
datetime = fmt.Sprintf("%v", v)
|
||||
)
|
||||
|
||||
if onlyTime {
|
||||
internalFormat = datetimeIntenralFormatTime
|
||||
inputFormats = []string{
|
||||
datetimeIntenralFormatTime,
|
||||
"15:04",
|
||||
"15:04:05Z07:00",
|
||||
"15:04:05 MST",
|
||||
"15:04:05 -0700",
|
||||
"15:04 MST",
|
||||
"15:04Z07:00",
|
||||
"15:04 -0700",
|
||||
time.Kitchen,
|
||||
}
|
||||
} else {
|
||||
if onlyDate {
|
||||
// In case only date is used, make sure we format it properly
|
||||
internalFormat = datetimeInternalFormatDate
|
||||
} else {
|
||||
internalFormat = datetimeInternalFormatFull
|
||||
}
|
||||
|
||||
// date & time
|
||||
inputFormats = []string{
|
||||
datetimeInternalFormatFull,
|
||||
"2006-01-02T15:04:05", // iso8601 without timezone
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.RFC822Z,
|
||||
time.RFC822,
|
||||
time.RFC850,
|
||||
time.ANSIC,
|
||||
time.UnixDate,
|
||||
time.RubyDate,
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST", // Time.String()
|
||||
"2006-01-02",
|
||||
"02 Jan 2006",
|
||||
"2006-01-02T15:04:05-0700", // RFC3339 without timezone hh:mm colon
|
||||
"2006-01-02 15:04:05 -07:00",
|
||||
"2006-01-02 15:04:05 -0700",
|
||||
"2006-01-02 15:04:05Z07:00", // RFC3339 without T
|
||||
"2006-01-02 15:04:05Z0700", // RFC3339 without T or timezone hh:mm colon
|
||||
"2006-01-02 15:04:05",
|
||||
time.Kitchen,
|
||||
time.Stamp,
|
||||
time.StampMilli,
|
||||
time.StampMicro,
|
||||
time.StampNano,
|
||||
datetimeInternalFormatDate,
|
||||
"02 Jan 06",
|
||||
"Monday, 02-Jan-06",
|
||||
"Mon, 02 Jan 2006",
|
||||
"2006/_1/_2",
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range inputFormats {
|
||||
parsed, err := time.Parse(format, datetime)
|
||||
if err == nil {
|
||||
return parsed.UTC().Format(internalFormat)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func sNumber(num interface{}, p uint, s uint) string {
|
||||
base, err := strconv.ParseFloat(fmt.Sprintf("%v", num), 64)
|
||||
if err != nil {
|
||||
return "0"
|
||||
}
|
||||
|
||||
// Format the value to the desired precision
|
||||
str := strconv.FormatFloat(base, 'f', int(s), 64)
|
||||
|
||||
// In case of fractures, remove trailing 0's
|
||||
if strings.Contains(str, ".") {
|
||||
str = strings.TrimRight(str, "0")
|
||||
str = strings.TrimRight(str, ".")
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
func sString(str interface{}) string {
|
||||
base, err := cast.ToStringE(str)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return xss.RichText(base)
|
||||
}
|
||||
|
||||
// sanitize casts value to field kind format
|
||||
func sanitize(f *types.ModuleField, v interface{}) string {
|
||||
switch strings.ToLower(f.Kind) {
|
||||
case "bool":
|
||||
return sBool(v)
|
||||
case "datetime":
|
||||
v = sDatetime(v, f.Options.Bool("onlyDate"), f.Options.Bool("onlyTime"))
|
||||
case "number":
|
||||
// @todo !! This is temporary; precision and scale require a rework
|
||||
v = sNumber(v, maxPrecision, f.Options.Precision())
|
||||
case "string":
|
||||
v = sString(v)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
402
server/compose/service/values/sanitizer_test.go
Normal file
402
server/compose/service/values/sanitizer_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
)
|
||||
|
||||
func Test_sanitizer_Run(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
options map[string]interface{}
|
||||
input string
|
||||
output string
|
||||
outref uint64
|
||||
}{
|
||||
{
|
||||
name: "numbers should be trimmed",
|
||||
kind: "Number",
|
||||
input: " 42 ",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "object reference should be processed",
|
||||
kind: "Record",
|
||||
input: " 133569629112020995 ",
|
||||
output: "133569629112020995",
|
||||
outref: 133569629112020995,
|
||||
},
|
||||
{
|
||||
name: "object reference should be numeric",
|
||||
kind: "Record",
|
||||
input: " foo ",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "user reference should be processed",
|
||||
kind: "User",
|
||||
input: " 133569629112020995 ",
|
||||
output: "133569629112020995",
|
||||
outref: 133569629112020995,
|
||||
},
|
||||
{
|
||||
name: "user reference should be numeric",
|
||||
kind: "User",
|
||||
input: " foo ",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "strings should be kept intact",
|
||||
kind: "String",
|
||||
input: " The answer ",
|
||||
output: " The answer ",
|
||||
},
|
||||
{
|
||||
name: "booleans should be converted (t)",
|
||||
kind: "Bool",
|
||||
input: "t",
|
||||
output: "1",
|
||||
},
|
||||
{
|
||||
name: "booleans should be converted (false)",
|
||||
kind: "Bool",
|
||||
input: "false",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "booleans should be converted (garbage)",
|
||||
kind: "Bool",
|
||||
input: "%%#)%)')$)'",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "dates should be converted to ISO",
|
||||
kind: "DateTime",
|
||||
input: "Mon Jan 2 15:04:05 2006",
|
||||
output: "2006-01-02T15:04:05Z",
|
||||
},
|
||||
{
|
||||
name: "dates should be converted to UTC",
|
||||
kind: "DateTime",
|
||||
input: "2020-03-02T20:20:20+05:00",
|
||||
output: "2020-03-02T15:20:20Z",
|
||||
},
|
||||
{
|
||||
name: "micro/mili seconds should be cut off",
|
||||
kind: "DateTime",
|
||||
input: "2020-03-11T11:20:08.471Z",
|
||||
output: "2020-03-11T11:20:08Z",
|
||||
},
|
||||
{
|
||||
name: "number space trim",
|
||||
kind: "Number",
|
||||
input: " 42 ",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number negative",
|
||||
kind: "Number",
|
||||
input: "-42",
|
||||
output: "-42",
|
||||
},
|
||||
{
|
||||
name: "number positive",
|
||||
kind: "Number",
|
||||
input: "+42",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 3},
|
||||
input: "42.44455",
|
||||
output: "42.445",
|
||||
},
|
||||
{
|
||||
name: "number precision; bigger precision then provided fracture",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 3},
|
||||
input: "42.4",
|
||||
output: "42.4",
|
||||
},
|
||||
{
|
||||
name: "number precision; default to 0",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{},
|
||||
input: "42.4",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision; clamped between [0, 6]",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 10},
|
||||
input: "42.5555555555",
|
||||
output: "42.555556",
|
||||
},
|
||||
{
|
||||
name: "number precision; round",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 0},
|
||||
input: "41.6",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision; trailing .00",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 2},
|
||||
input: "42.00",
|
||||
output: "42",
|
||||
},
|
||||
{
|
||||
name: "number precision; trailing .040",
|
||||
kind: "Number",
|
||||
options: map[string]interface{}{"precision": 2},
|
||||
input: "42.040",
|
||||
output: "42.04",
|
||||
},
|
||||
{
|
||||
name: "string escaping; html",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: "<span onerror=alert()>Title here</span>",
|
||||
output: "<span>Title here</span>",
|
||||
},
|
||||
{
|
||||
name: "string escaping; html a.href with javascript alert",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<a href="javascript:alert('XSS1')" onmouseover="alert('XSS2')">XSS<a>`,
|
||||
output: "XSS",
|
||||
},
|
||||
{
|
||||
name: "string escaping; a.href with javascript",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<a href="javascript:document.location='https://cortezaproject.org/'">XSS</A>`,
|
||||
output: "XSS",
|
||||
},
|
||||
{
|
||||
name: "string escaping; script with script",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<script>document.write("<scri");</script>pt src="https://cortezaproject.org/script.js"></script>`,
|
||||
output: `pt src="https://cortezaproject.org/script.js">`,
|
||||
},
|
||||
{
|
||||
name: "string escaping; inline styles unchanged",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<span style="background-color: #00ff00"><span style="color: #4a86e8">nasty looking content</span></span>`,
|
||||
output: `<span style="background-color: #00ff00"><span style="color: #4a86e8">nasty looking content</span></span>`,
|
||||
},
|
||||
{
|
||||
name: "string escaping; script with a",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<script a=">'>" src="https://cortezaproject.org/xss.js"></script>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; meta with script",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<meta http-equiv="set-cookie" content="<script>alert('xss')</script>">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; object",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<object type="text/x-scriptlet" data="https://cortezaproject.org/xss.html"></object>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; base href",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<base href="javascript:alert('xss');//">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; script",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<!--[if gte ie 4]><script>alert('xss');</script><![endif]-->`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; div",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<div style="background-image: url(javascript:alert('XSS'))"></div>`,
|
||||
output: "<div></div>",
|
||||
},
|
||||
{
|
||||
name: "string escaping; frameset",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<frameset><frame src="javascript:alert('XSS');"></frameset>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; iframe",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<iframe src=# onmouseover="alert(document.cookie)"></iframe>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; meta",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<meta http-equiv="refresh" content="0; url=https://;url=javascript:alert('xss');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; br",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<br size="&{alert('XSS')}">`,
|
||||
output: "<br>",
|
||||
},
|
||||
{
|
||||
name: "string escaping; bgsound",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<bgsound src="javascript:alert('XSS');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; input type image",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<input type="image" src="javascript:alert('XSS');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; style",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<style>@import 'https://cortezaproject.org/xss.css';</style>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; link",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<link rel="stylesheet" href="javascript:alert('xss');">`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; html body onload event",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<body onload=alert('XSS')>`,
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "string escaping; xss element",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `'';!--"<XSS>=&{()}`,
|
||||
output: `'';!--"=&{()}`,
|
||||
},
|
||||
{
|
||||
name: "string escaping; xss element",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `Hello <span class="><script src='https://cortezaproject.org/XSS.js'></script>">there</span> world.`,
|
||||
output: "Hello <span>there</span> world.",
|
||||
},
|
||||
{
|
||||
name: "string escaping; xss element",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `<tag1>cor<tag2></tag2>teza</tag1><tag1>server</tag1><tag2>123</tag2>`,
|
||||
output: "cortezaserver123",
|
||||
},
|
||||
{
|
||||
name: "string escaping; preserve necessary chars",
|
||||
kind: "String",
|
||||
options: map[string]interface{}{},
|
||||
input: `a < b ; "'"`,
|
||||
output: `a < b ; "'"`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := sanitizer{}
|
||||
m := &types.Module{Fields: types.ModuleFieldSet{&types.ModuleField{Name: "testField", Kind: tt.kind, Options: tt.options}}}
|
||||
v := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.input}}
|
||||
o := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.output, Ref: tt.outref}}
|
||||
|
||||
// Need to mark values as updated to trigger sanitization.
|
||||
v.SetUpdatedFlag(true)
|
||||
o.SetUpdatedFlag(true)
|
||||
if sanitized := s.Run(m, v); !reflect.DeepEqual(sanitized, o) {
|
||||
t.Errorf("\ninput value:\n%v\n\nresult of sanitization:\n%v\n\nexpected:\n%v\n", tt.input, sanitized, o)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizerExpr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
expr []string
|
||||
input string
|
||||
output string
|
||||
outref uint64
|
||||
}{
|
||||
{
|
||||
name: "cap numbers",
|
||||
kind: "Number",
|
||||
expr: []string{`value > 42 ? 42 : value`},
|
||||
input: "43",
|
||||
output: "42",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := sanitizer{}
|
||||
m := &types.Module{Fields: types.ModuleFieldSet{&types.ModuleField{Name: "testField", Kind: tt.kind}}}
|
||||
m.Fields.FindByName("testField").Expressions.Sanitizers = tt.expr
|
||||
v := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.input}}
|
||||
o := types.RecordValueSet{&types.RecordValue{Name: "testField", Value: tt.output, Ref: tt.outref}}
|
||||
|
||||
// Need to mark values as updated to trigger sanitization.
|
||||
v.SetUpdatedFlag(true)
|
||||
o.SetUpdatedFlag(true)
|
||||
if sanitized := s.Run(m, v); !reflect.DeepEqual(sanitized, o) {
|
||||
t.Errorf("\ninput value:\n%v\n\nresult of sanitization:\n%v\n\nexpected:\n%v\n", tt.input, sanitized, o)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatetimeSanitizer(t *testing.T) {
|
||||
tests := []struct {
|
||||
input interface{}
|
||||
onlyDate, onlyTime bool
|
||||
rval string
|
||||
}{
|
||||
{time.Date(1999, 9, 9, 9, 9, 9, 9, time.UTC), false, false, "1999-09-09T09:09:09Z"},
|
||||
{"2021-03-23T20:21:15Z", false, false, "2021-03-23T20:21:15Z"},
|
||||
{"2021-03-23T20:21:15+01:00", false, false, "2021-03-23T19:21:15Z"},
|
||||
{"2021-03-23", true, false, "2021-03-23"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
|
||||
assert.New(t).Equal(tt.rval, sDatetime(tt.input, tt.onlyDate, tt.onlyTime))
|
||||
})
|
||||
}
|
||||
}
|
||||
37
server/compose/service/values/shared.go
Normal file
37
server/compose/service/values/shared.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
strBoolTrue = "1"
|
||||
strBoolFalse = "0"
|
||||
strBoolFalseAlt = ""
|
||||
|
||||
datetimeInternalFormatDate = "2006-01-02"
|
||||
datetimeIntenralFormatTime = "15:04:05"
|
||||
datetimeInternalFormatFull = time.RFC3339
|
||||
|
||||
fieldOpt_Datetime_onlyDate = "onlyDate"
|
||||
fieldOpt_Datetime_onlyTime = "onlyTime"
|
||||
fieldOpt_Datetime_onlyFutureValues = "onlyFutureValues"
|
||||
fieldOpt_Datetime_onlyPastValues = "onlyPastValues"
|
||||
|
||||
fieldOpt_Url_onlySecure = "onlySecure"
|
||||
|
||||
maxPrecision = 15
|
||||
)
|
||||
|
||||
var (
|
||||
// value resembles something that can be true
|
||||
truthy = regexp.MustCompile(`^(t(rue)?|y(es)?|1)$`)
|
||||
|
||||
// value resembles something that can be a reference
|
||||
refy = regexp.MustCompile(`^[1-9](\d*)$`)
|
||||
|
||||
// value resembels something that can be parsed as ISO 8601
|
||||
isoDaty = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
|
||||
hasTimezone = regexp.MustCompile(`(Z|\+\d{2}:\d{2})$`)
|
||||
)
|
||||
472
server/compose/service/values/validator.go
Normal file
472
server/compose/service/values/validator.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/expr"
|
||||
"github.com/cortezaproject/corteza/server/pkg/slice"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
)
|
||||
|
||||
// Validator package provides tooling to validate
|
||||
// record and it's values against field constraints
|
||||
//
|
||||
// Structures and basic logic is similar to what we offer on the frontend
|
||||
// (see corteza-js/validator package) but with less features as there
|
||||
// is no need for such level of interaction and dynamic we require on the frontend
|
||||
|
||||
type (
|
||||
UniqueChecker func(context.Context, store.Storer, *types.RecordValue, *types.ModuleField, *types.Module) (uint64, error)
|
||||
ReferenceChecker func(context.Context, store.Storer, *types.RecordValue, *types.ModuleField, *types.Module) (bool, error)
|
||||
|
||||
localeService interface {
|
||||
T(ctx context.Context, ns, key string, rr ...string) string
|
||||
TResource(ctx context.Context, ns, key string, rr ...string) string
|
||||
}
|
||||
|
||||
validator struct {
|
||||
uniqueCheckerFn UniqueChecker
|
||||
recordRefCheckerFn ReferenceChecker
|
||||
userRefCheckerFn ReferenceChecker
|
||||
fileRefCheckerFn ReferenceChecker
|
||||
|
||||
localeSvc localeService
|
||||
|
||||
now func() time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func makeInternalErr(field *types.ModuleField, err error) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "internal",
|
||||
Message: err.Error(),
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
}
|
||||
}
|
||||
|
||||
func makeEmptyErr(ctx context.Context, field *types.ModuleField, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "empty",
|
||||
Meta: map[string]interface{}{"field": field.Name},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.empty"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeInvalidValueErr(ctx context.Context, field *types.ModuleField, value string, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "invalidValue",
|
||||
Meta: map[string]interface{}{"field": field.Name, "value": value},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.invalidValue"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeInvalidRefErr(ctx context.Context, field *types.ModuleField, ref uint64, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "invalidRef",
|
||||
Meta: map[string]interface{}{"field": field.Name, "ref": ref},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.invalidRef"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeDuplicateValueInSetErr(ctx context.Context, field *types.ModuleField, value string, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "duplicateValueInSet",
|
||||
Meta: map[string]interface{}{"field": field.Name, "value": value},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.duplicateValueInSet"),
|
||||
}
|
||||
}
|
||||
|
||||
func makeDuplicateValueErr(ctx context.Context, field *types.ModuleField, recordID uint64, ls localeService) types.RecordValueError {
|
||||
return types.RecordValueError{
|
||||
Kind: "duplicateValue",
|
||||
Meta: map[string]interface{}{"field": field.Name, "recordID": recordID},
|
||||
Message: ls.T(ctx, "compose", "record-field.errors.duplicateValue"),
|
||||
}
|
||||
}
|
||||
|
||||
// Simple wrapper for easier error returning from validation functions
|
||||
func e2s(ee ...types.RecordValueError) []types.RecordValueError {
|
||||
return ee
|
||||
}
|
||||
|
||||
func Validator() *validator {
|
||||
return &validator{
|
||||
now: func() time.Time { return time.Now() },
|
||||
localeSvc: locale.Global(),
|
||||
}
|
||||
}
|
||||
|
||||
func (vldtr *validator) UniqueChecker(fn UniqueChecker) {
|
||||
vldtr.uniqueCheckerFn = fn
|
||||
}
|
||||
|
||||
func (vldtr *validator) RecordRefChecker(fn ReferenceChecker) {
|
||||
vldtr.recordRefCheckerFn = fn
|
||||
}
|
||||
|
||||
func (vldtr *validator) UserRefChecker(fn ReferenceChecker) {
|
||||
vldtr.userRefCheckerFn = fn
|
||||
}
|
||||
|
||||
func (vldtr *validator) FileRefChecker(fn ReferenceChecker) {
|
||||
vldtr.fileRefCheckerFn = fn
|
||||
}
|
||||
|
||||
// Run validates record and it's values against module & module fields options
|
||||
//
|
||||
// Validation is done in phases for optimal resource usage:
|
||||
// - check if required values are present
|
||||
// - check for unique-multi-value in multi value fields
|
||||
// - field-kind specific validation on all values
|
||||
// - unique check on all values
|
||||
func (vldtr validator) Run(ctx context.Context, s store.Storer, m *types.Module, r *types.Record) (out *types.RecordValueErrorSet) {
|
||||
var (
|
||||
f *types.ModuleField
|
||||
|
||||
valParser = expr.Parser()
|
||||
valDict = r.Values.Dict(m.Fields)
|
||||
)
|
||||
|
||||
out = &types.RecordValueErrorSet{}
|
||||
|
||||
fields:
|
||||
for _, f := range m.Fields {
|
||||
vv := r.Values.FilterByName(f.Name)
|
||||
|
||||
if f.Required {
|
||||
if len(vv) == 0 {
|
||||
out.Push(makeEmptyErr(ctx, f, vldtr.localeSvc))
|
||||
continue fields
|
||||
}
|
||||
|
||||
for _, v := range vv {
|
||||
if len(v.Value) == 0 || (f.IsRef() && v.Ref == 0) {
|
||||
out.Push(makeEmptyErr(ctx, f, vldtr.localeSvc))
|
||||
continue fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if f.Multi && f.Options.IsUniqueMultiValue() {
|
||||
flipped := make(map[string]bool)
|
||||
for _, v := range vv {
|
||||
if flipped[v.Value] {
|
||||
out.Push(makeDuplicateValueInSetErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
continue fields
|
||||
}
|
||||
|
||||
flipped[v.Value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range r.Values {
|
||||
if !v.IsUpdated() || v.IsDeleted() {
|
||||
// We'll validate only updated (and non-deleted) values
|
||||
continue
|
||||
}
|
||||
|
||||
if f = m.Fields.FindByName(v.Name); f == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.Expressions.ValueExpr != "" {
|
||||
// do not do any validation if field has value expression!
|
||||
continue
|
||||
}
|
||||
|
||||
if !(f.Expressions.DisableDefaultValidators && len(f.Expressions.Validators) > 0) {
|
||||
if v.Value == "" {
|
||||
// Nothing to do with empty value
|
||||
return nil
|
||||
}
|
||||
|
||||
// Per field type validators
|
||||
switch strings.ToLower(f.Kind) {
|
||||
case "bool":
|
||||
out.Push(vldtr.vBool(ctx, v, f, r, m)...)
|
||||
case "datetime":
|
||||
out.Push(vldtr.vDatetime(ctx, v, f, r, m)...)
|
||||
case "email":
|
||||
out.Push(vldtr.vEmail(ctx, v, f, r, m)...)
|
||||
case "file":
|
||||
out.Push(vldtr.vFile(ctx, s, v, f, r, m)...)
|
||||
case "number":
|
||||
out.Push(vldtr.vNumber(ctx, v, f, r, m)...)
|
||||
case "record":
|
||||
out.Push(vldtr.vRecord(ctx, s, v, f, r, m)...)
|
||||
case "select":
|
||||
out.Push(vldtr.vSelect(ctx, v, f, r, m)...)
|
||||
//case "string":
|
||||
// out.Push(vldtr.vString(v, f, r, m)...)
|
||||
case "url":
|
||||
out.Push(vldtr.vUrl(ctx, v, f, r, m)...)
|
||||
case "user":
|
||||
out.Push(vldtr.vUser(ctx, s, v, f, r, m)...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(f.Expressions.Validators) > 0 {
|
||||
for i, cv := range f.Expressions.Validators {
|
||||
eval, err := valParser.NewEvaluable(cv.Test)
|
||||
if err != nil {
|
||||
out.Push(makeInternalErr(f, err))
|
||||
break
|
||||
}
|
||||
|
||||
invalid, err := eval.EvalBool(ctx, map[string]interface{}{
|
||||
"value": v.Value,
|
||||
"oldValue": v.OldValue,
|
||||
"values": valDict,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
out.Push(makeInternalErr(f, err))
|
||||
break
|
||||
}
|
||||
|
||||
if invalid {
|
||||
message := cv.Error
|
||||
|
||||
// In case tests mit this for easier testing; falling back to whatever
|
||||
// the field validator has stored.
|
||||
if vldtr.localeSvc != nil {
|
||||
validatorID := locale.ContentID(cv.ValidatorID, i)
|
||||
rpl := strings.NewReplacer(
|
||||
"{{validatorID}}", strconv.FormatUint(validatorID, 10),
|
||||
)
|
||||
|
||||
k := rpl.Replace(types.LocaleKeyModuleFieldExpressionValidatorValidatorIDError.Path)
|
||||
message = vldtr.localeSvc.TResource(ctx, f.ResourceTranslation(), k)
|
||||
}
|
||||
|
||||
out.Push(types.RecordValueError{
|
||||
Kind: "error",
|
||||
Message: message,
|
||||
Meta: map[string]interface{}{"field": f.Name}},
|
||||
)
|
||||
|
||||
// break at first failed test
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is the most resource-heavy operation
|
||||
// we'll do in at the end
|
||||
for _, v := range r.Values {
|
||||
if f = m.Fields.FindByName(v.Name); f == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !f.Options.IsUnique() {
|
||||
// Only interested in unique fields
|
||||
continue
|
||||
}
|
||||
|
||||
if vldtr.uniqueCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
duplicateRecordID, err := vldtr.uniqueCheckerFn(ctx, s, v, f, m)
|
||||
if err != nil {
|
||||
out.Push(makeInternalErr(f, err))
|
||||
} else if duplicateRecordID > 0 && duplicateRecordID != r.ID {
|
||||
out.Push(makeDuplicateValueErr(ctx, f, duplicateRecordID, vldtr.localeSvc))
|
||||
}
|
||||
}
|
||||
|
||||
if out.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (vldtr validator) vBool(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if v.Value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if v.Value != strBoolTrue && v.Value != strBoolFalse && v.Value != strBoolFalseAlt {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vDatetime(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
var (
|
||||
inputFormat string
|
||||
t time.Time
|
||||
err error
|
||||
|
||||
// We'll validate against this value
|
||||
refTime = vldtr.now()
|
||||
)
|
||||
|
||||
if f.Options.Bool(fieldOpt_Datetime_onlyDate) {
|
||||
inputFormat = datetimeInternalFormatDate
|
||||
|
||||
// Round down ref time to midnight
|
||||
refTime = time.Date(refTime.Year(), refTime.Month(), refTime.Day(), 0, 0, 0, 0, refTime.Location())
|
||||
} else if f.Options.Bool(fieldOpt_Datetime_onlyTime) {
|
||||
inputFormat = datetimeIntenralFormatTime
|
||||
|
||||
// Round down ref time to day one
|
||||
refTime = time.Date(0, 1, 1, refTime.Hour(), refTime.Minute(), refTime.Second(), refTime.Nanosecond(), refTime.Location())
|
||||
} else {
|
||||
inputFormat = datetimeInternalFormatFull
|
||||
}
|
||||
|
||||
t, err = time.Parse(inputFormat, v.Value)
|
||||
if err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
if f.Options.Bool(fieldOpt_Datetime_onlyFutureValues) {
|
||||
if !t.After(refTime) {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
} else if f.Options.Bool(fieldOpt_Datetime_onlyPastValues) {
|
||||
if !t.Before(refTime) {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
}
|
||||
|
||||
// @todo check past/future
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vEmail(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if _, err := mail.ParseAddress(v.Value); err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vFile(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if vldtr.fileRefCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, err := vldtr.fileRefCheckerFn(ctx, s, v, f, m); err != nil {
|
||||
return e2s(makeInternalErr(f, err))
|
||||
} else if !ok {
|
||||
return e2s(makeInvalidRefErr(ctx, f, v.Ref, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vNumber(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
// @todo !! This is temporary; precision and scale require a rework
|
||||
if _, _, err := big.ParseFloat(v.Value, 0, maxPrecision, big.ToNearestEven); err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vRecord(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if vldtr.recordRefCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ok, err := vldtr.recordRefCheckerFn(ctx, s, v, f, m); err != nil {
|
||||
return e2s(makeInternalErr(f, err))
|
||||
} else if !ok {
|
||||
return e2s(makeInvalidRefErr(ctx, f, v.Ref, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vSelect(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
var (
|
||||
sbm = make(map[string]bool)
|
||||
options, has = f.Options["options"]
|
||||
)
|
||||
|
||||
if !has {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch oo := options.(type) {
|
||||
case []string:
|
||||
sbm = slice.ToStringBoolMap(oo)
|
||||
case []interface{}:
|
||||
for _, o := range oo {
|
||||
switch c := o.(type) {
|
||||
case string:
|
||||
sbm[c] = true
|
||||
case map[string]string:
|
||||
if value, has := c["value"]; has {
|
||||
sbm[value] = true
|
||||
}
|
||||
case map[string]interface{}:
|
||||
if value, has := c["value"]; has {
|
||||
if value, ok := value.(string); ok {
|
||||
sbm[value] = true
|
||||
}
|
||||
}
|
||||
case types.ModuleFieldOptions:
|
||||
if value, has := c["value"]; has {
|
||||
if value, ok := value.(string); ok {
|
||||
sbm[value] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(sbm) == 0 {
|
||||
return e2s(makeInternalErr(f, fmt.Errorf("invalid select options definition")))
|
||||
}
|
||||
|
||||
if !sbm[v.Value] {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//func (vldtr validator) vString(v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
// return nil
|
||||
//}
|
||||
|
||||
func (vldtr validator) vUrl(ctx context.Context, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if p, err := url.Parse(v.Value); err != nil {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
} else if p.Scheme == "" || p.Host == "" {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
} else if f.Options.Bool(fieldOpt_Url_onlySecure) && p.Scheme != "https" {
|
||||
return e2s(makeInvalidValueErr(ctx, f, v.Value, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vldtr validator) vUser(ctx context.Context, s store.Storer, v *types.RecordValue, f *types.ModuleField, r *types.Record, m *types.Module) []types.RecordValueError {
|
||||
if vldtr.userRefCheckerFn == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, err := vldtr.userRefCheckerFn(ctx, s, v, f, m); err != nil {
|
||||
return e2s(makeInternalErr(f, err))
|
||||
} else if !ok {
|
||||
return e2s(makeInvalidRefErr(ctx, f, v.Ref, vldtr.localeSvc))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
258
server/compose/service/values/validator_test.go
Normal file
258
server/compose/service/values/validator_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package values
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cortezaproject/corteza/server/compose/types"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/stretchr/testify/require"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeLocaleService() localeService {
|
||||
return locale.Global()
|
||||
}
|
||||
|
||||
func Test_validator_vDatetime(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
now: func() time.Time {
|
||||
t, err := time.Parse(time.RFC3339, "2020-02-20T10:10:10Z")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
},
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "unparsable",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "unparsable"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid datetime value",
|
||||
val: "2020-02-20T10:10:10Z",
|
||||
},
|
||||
{
|
||||
name: "valid date value",
|
||||
val: "2020-02-20",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyDate: true},
|
||||
},
|
||||
{
|
||||
name: "valid time value",
|
||||
val: "10:10:10",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyTime: true},
|
||||
},
|
||||
{
|
||||
name: "valid future datetime value",
|
||||
val: "2021-02-20T10:10:10Z",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyFutureValues: true},
|
||||
},
|
||||
{
|
||||
name: "valid future date value",
|
||||
val: "2021-02-20",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyFutureValues: true, fieldOpt_Datetime_onlyDate: true},
|
||||
},
|
||||
{
|
||||
name: "valid future time value",
|
||||
val: "11:10:10",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyFutureValues: true, fieldOpt_Datetime_onlyTime: true},
|
||||
},
|
||||
{
|
||||
name: "valid past datetime value",
|
||||
val: "2019-02-20T10:10:10Z",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyPastValues: true},
|
||||
},
|
||||
{
|
||||
name: "valid past date value",
|
||||
val: "2019-02-20",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyPastValues: true, fieldOpt_Datetime_onlyDate: true},
|
||||
},
|
||||
{
|
||||
name: "valid past time value",
|
||||
val: "09:10:10",
|
||||
opt: types.ModuleFieldOptions{fieldOpt_Datetime_onlyPastValues: true, fieldOpt_Datetime_onlyTime: true},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vDatetime(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vDatetime() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vNumber(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "unparsable",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "unparsable"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid number value",
|
||||
val: "42",
|
||||
},
|
||||
{
|
||||
name: "valid number value",
|
||||
val: "42.123",
|
||||
},
|
||||
{
|
||||
name: "valid number value",
|
||||
val: "412412412.322894325892365",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vNumber(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vNumber() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vUrl(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "invalid-url",
|
||||
val: "invalid-url",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "invalid-url"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "https://crust.tech/",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vUrl(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vUrl() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vEmail(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "un pars able",
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "un pars able"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "qa@crust.tech",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vEmail(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vEmail() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_vSelect(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{
|
||||
localeSvc: makeLocaleService(),
|
||||
}
|
||||
tests = []struct {
|
||||
name string
|
||||
val string
|
||||
opt types.ModuleFieldOptions
|
||||
want []types.RecordValueError
|
||||
}{
|
||||
{
|
||||
name: "unparsable",
|
||||
val: "dummy",
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "crust",
|
||||
opt: types.ModuleFieldOptions{"options": []string{"crust", "corteza"}},
|
||||
},
|
||||
{
|
||||
name: "valid value",
|
||||
val: "the rest",
|
||||
opt: types.ModuleFieldOptions{"options": []string{"crust", "corteza"}},
|
||||
want: e2s(types.RecordValueError{Kind: "invalidValue", Meta: map[string]interface{}{"field": "", "value": "the rest"}, Message: "record-field.errors.invalidValue"}),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := vldtr.vSelect(context.Background(), &types.RecordValue{Value: tt.val}, &types.ModuleField{Options: tt.opt}, nil, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("vSelect() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validator_customExpr(t *testing.T) {
|
||||
var (
|
||||
vldtr = validator{}
|
||||
m = &types.Module{}
|
||||
f = &types.ModuleField{Name: "num", Kind: "Number"}
|
||||
r = &types.Record{}
|
||||
)
|
||||
|
||||
f.Expressions.Validators = []types.ModuleFieldValidator{
|
||||
{Test: "value < 5", Error: "value is lower than 5"},
|
||||
}
|
||||
m.Fields = append(m.Fields, f)
|
||||
r.Values = r.Values.Replace("num", "1")
|
||||
|
||||
rve := vldtr.Run(context.Background(), nil, m, r)
|
||||
require.False(t, rve.IsValid())
|
||||
|
||||
r.Values = r.Values.Replace("num", "10")
|
||||
rve = vldtr.Run(context.Background(), nil, m, r)
|
||||
require.True(t, rve.IsValid())
|
||||
}
|
||||
Reference in New Issue
Block a user