@@ -32,6 +32,7 @@ GO_BUILD= $(GO_BASE) build $(LDFLAGS) $(GCFLAGS)
|
||||
GO_GENERATE= $(GO_BASE) generate
|
||||
GO_TEST= $(GO_BASE) tool gotestsum -- $(TEST_FLAGS)
|
||||
GO_VET= $(GO_BASE) vet
|
||||
GO_TOOL= $(GO_BASE) tool
|
||||
|
||||
TEST_FLAGS?= -race -cover -coverprofile=coverage.out
|
||||
|
||||
@@ -281,3 +282,8 @@ compose/keycloak/probo-realm.json: compose/keycloak/probo-realm.json.tmpl compos
|
||||
apps/console/dist/index.html apps/trust/dist/index.html:
|
||||
$(MKDIR) $(dir $@)
|
||||
$(ECHO) dev-server > $@
|
||||
|
||||
|
||||
.PHONY: deadcode
|
||||
deadcode:
|
||||
$(GO_TOOL) deadcode ./... | grep -v "With" | grep -v "UnmarshalBigIntScalar" | grep -v "^e2e/"
|
||||
21
go.mod
21
go.mod
@@ -9,7 +9,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/crewjam/saml v0.5.1
|
||||
@@ -34,14 +34,16 @@ require (
|
||||
go.opentelemetry.io/otel v1.40.0
|
||||
go.opentelemetry.io/otel/trace v1.40.0
|
||||
go.probo.inc/mcpgen v0.0.0-20260223192226-386b1fbe6184
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/api v0.269.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
@@ -134,12 +136,12 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
@@ -152,6 +154,7 @@ require (
|
||||
tool (
|
||||
github.com/99designs/gqlgen
|
||||
go.probo.inc/mcpgen
|
||||
golang.org/x/tools/cmd/deadcode
|
||||
gotest.tools/gotestsum
|
||||
)
|
||||
|
||||
|
||||
38
go.sum
38
go.sum
@@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
|
||||
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow=
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
@@ -296,27 +296,29 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
|
||||
@@ -38,11 +38,6 @@ func NewBuilder(getEnv EnvGetter) *Builder {
|
||||
return &Builder{getEnv: getEnv}
|
||||
}
|
||||
|
||||
func (b *Builder) SetSAMLCredentials(certificate, privateKey string) {
|
||||
b.samlCertificate = certificate
|
||||
b.samlPrivateKey = privateKey
|
||||
}
|
||||
|
||||
func (b *Builder) Build() (*probod.FullConfig, error) {
|
||||
if err := b.validateRequired(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -92,7 +92,8 @@ func TestBuilder_Build_MissingRequiredEnvVars(t *testing.T) {
|
||||
|
||||
func TestBuilder_Build_Defaults(t *testing.T) {
|
||||
b := NewBuilder(mockEnv(requiredEnv()))
|
||||
b.SetSAMLCredentials("test-cert", "test-key")
|
||||
b.samlCertificate = "test-cert"
|
||||
b.samlPrivateKey = "test-key"
|
||||
|
||||
cfg, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
@@ -243,7 +244,8 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
|
||||
env["ESIGN_TSA_URL"] = "http://custom.tsa.example.com"
|
||||
|
||||
b := NewBuilder(mockEnv(env))
|
||||
b.SetSAMLCredentials("test-cert", "test-key")
|
||||
b.samlCertificate = "test-cert"
|
||||
b.samlPrivateKey = "test-key"
|
||||
|
||||
cfg, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
@@ -313,7 +315,8 @@ func TestBuilder_Build_SlackConnector(t *testing.T) {
|
||||
env["CONNECTOR_SLACK_REDIRECT_URI"] = "https://app.example.com/api/console/v1/connectors/complete"
|
||||
|
||||
b := NewBuilder(mockEnv(env))
|
||||
b.SetSAMLCredentials("test-cert", "test-key")
|
||||
b.samlCertificate = "test-cert"
|
||||
b.samlPrivateKey = "test-key"
|
||||
|
||||
cfg, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
@@ -343,7 +346,8 @@ func TestBuilder_Build_SlackConnector_CustomURLs(t *testing.T) {
|
||||
env["CONNECTOR_SLACK_TOKEN_URL"] = "https://custom.slack.com/oauth/token"
|
||||
|
||||
b := NewBuilder(mockEnv(env))
|
||||
b.SetSAMLCredentials("test-cert", "test-key")
|
||||
b.samlCertificate = "test-cert"
|
||||
b.samlPrivateKey = "test-key"
|
||||
|
||||
cfg, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
@@ -382,7 +386,8 @@ func TestBuilder_Build_SAMLFromEnv(t *testing.T) {
|
||||
|
||||
func TestBuilder_Build_SAMLPreset(t *testing.T) {
|
||||
b := NewBuilder(mockEnv(requiredEnv()))
|
||||
b.SetSAMLCredentials("preset-cert", "preset-key")
|
||||
b.samlCertificate = "preset-cert"
|
||||
b.samlPrivateKey = "preset-key"
|
||||
|
||||
cfg, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
@@ -396,7 +401,8 @@ func TestBuilder_Build_PgCABundleFromEnv(t *testing.T) {
|
||||
env["PG_CA_BUNDLE"] = "test-ca-bundle-content"
|
||||
|
||||
b := NewBuilder(mockEnv(env))
|
||||
b.SetSAMLCredentials("test-cert", "test-key")
|
||||
b.samlCertificate = "test-cert"
|
||||
b.samlPrivateKey = "test-key"
|
||||
|
||||
cfg, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
@@ -414,7 +420,8 @@ func TestBuilder_Build_PgCABundleFromFile(t *testing.T) {
|
||||
env["PG_CA_BUNDLE_PATH"] = caFile
|
||||
|
||||
b := NewBuilder(mockEnv(env))
|
||||
b.SetSAMLCredentials("test-cert", "test-key")
|
||||
b.samlCertificate = "test-cert"
|
||||
b.samlPrivateKey = "test-key"
|
||||
|
||||
cfg, err := b.Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -141,32 +141,3 @@ func (w *CacheStore) warmDomain(ctx context.Context, conn pg.Conn, domain *cored
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *CacheStore) RefreshCache(ctx context.Context) error {
|
||||
w.logger.InfoCtx(ctx, "refreshing certificate cache")
|
||||
|
||||
return w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
cachedCertificates := coredata.CachedCertificates{}
|
||||
if err := cachedCertificates.CleanExpired(ctx, conn); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot clean expired cache", log.Error(err))
|
||||
}
|
||||
|
||||
return w.WarmCache(ctx)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (w *CacheStore) WarmSingleDomain(ctx context.Context, domainName string) error {
|
||||
return w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var domain coredata.CustomDomain
|
||||
if err := domain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domainName); err != nil {
|
||||
return fmt.Errorf("cannot load domain: %w", err)
|
||||
}
|
||||
|
||||
return w.warmDomain(ctx, conn, &domain)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,11 +160,3 @@ func (s *Selector) rebuildCacheEntry(ctx context.Context, conn pg.Conn, domain s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Selector) ClearCache() {
|
||||
s.cache.Range(
|
||||
func(key, _ any) bool {
|
||||
s.cache.Delete(key)
|
||||
return true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -566,46 +566,3 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sacs *ApplicabilityStatements) CountByControlID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
controlID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
WITH soac_ctrl AS (
|
||||
SELECT
|
||||
soac.id,
|
||||
soac.organization_id,
|
||||
soac.tenant_id
|
||||
FROM
|
||||
applicability_statements soac
|
||||
INNER JOIN
|
||||
states_of_applicability soa ON soac.state_of_applicability_id = soa.id
|
||||
WHERE
|
||||
soac.%[1]s
|
||||
AND soac.control_id = @control_id
|
||||
AND soa.snapshot_id IS NULL
|
||||
)
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
soac_ctrl
|
||||
WHERE
|
||||
%[1]s;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"control_id": controlID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -385,44 +385,6 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Audits) CountByControlID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
controlID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
WITH audits_by_control AS (
|
||||
SELECT
|
||||
a.id,
|
||||
a.tenant_id
|
||||
FROM
|
||||
audits a
|
||||
INNER JOIN
|
||||
controls_audits ca ON a.id = ca.audit_id
|
||||
WHERE
|
||||
ca.control_id = @control_id
|
||||
)
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
audits_by_control
|
||||
WHERE %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"control_id": controlID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (a *Audits) LoadByControlID(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -39,15 +39,6 @@ type (
|
||||
CachedCertificates []*CachedCertificate
|
||||
)
|
||||
|
||||
func NewCachedCertificate(domain string, domainID gid.GID) *CachedCertificate {
|
||||
now := time.Now()
|
||||
return &CachedCertificate{
|
||||
Domain: domain,
|
||||
CustomDomainID: domainID,
|
||||
CachedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (cc *CachedCertificate) LoadByDomain(ctx context.Context, conn pg.Conn, domain string) error {
|
||||
q := `
|
||||
SELECT
|
||||
|
||||
@@ -397,32 +397,3 @@ WHERE %s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ComplianceFrameworks) CountByTrustCenterID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
trustCenterID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
compliance_frameworks
|
||||
WHERE
|
||||
%s
|
||||
AND trust_center_id = @trust_center_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"trust_center_id": trustCenterID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
err := conn.QueryRow(ctx, q, args).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count compliance frameworks: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -72,44 +72,6 @@ func (c *Connector) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
|
||||
func (c *Connectors) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[ConnectorOrderField],
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
filter *ConnectorFilter,
|
||||
) error {
|
||||
if err := c.loadByOrganizationIDWithPagination(ctx, conn, scope, organizationID, cursor, filter); err != nil {
|
||||
return fmt.Errorf("cannot load connectors by organization ID: %w", err)
|
||||
}
|
||||
|
||||
if err := c.decryptConnections(encryptionKey); err != nil {
|
||||
return fmt.Errorf("cannot decrypt connections: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Connectors) LoadAllByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
) error {
|
||||
if err := c.loadAllByOrganizationID(ctx, conn, scope, organizationID); err != nil {
|
||||
return fmt.Errorf("cannot load all connectors by organization ID: %w", err)
|
||||
}
|
||||
|
||||
if err := c.decryptConnections(encryptionKey); err != nil {
|
||||
return fmt.Errorf("cannot decrypt connections: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Connectors) LoadAllByOrganizationIDProtocolAndProvider(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -696,52 +696,6 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controls) LoadByIDs(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
controlIDs []gid.GID,
|
||||
) error {
|
||||
if len(controlIDs) == 0 {
|
||||
*c = Controls{}
|
||||
return nil
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
section_title,
|
||||
framework_id,
|
||||
organization_id,
|
||||
name,
|
||||
description,
|
||||
best_practice,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
controls
|
||||
WHERE
|
||||
%s
|
||||
AND id = ANY(@control_ids)
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"control_ids": controlIDs}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query controls: %w", err)
|
||||
}
|
||||
|
||||
controls, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Control])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect controls: %w", err)
|
||||
}
|
||||
|
||||
*c = controls
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Control) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -866,49 +820,6 @@ WHERE %s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controls) CountByAuditID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
auditID gid.GID,
|
||||
filter *ControlFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
WITH ctrl AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.tenant_id,
|
||||
c.search_vector
|
||||
FROM
|
||||
controls c
|
||||
INNER JOIN
|
||||
controls_audits ca ON c.id = ca.control_id
|
||||
WHERE
|
||||
ca.audit_id = @audit_id
|
||||
)
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
ctrl
|
||||
WHERE %s
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"audit_id": auditID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *Controls) LoadByAuditID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -976,49 +887,6 @@ WHERE %s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controls) CountBySnapshotID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
snapshotID gid.GID,
|
||||
filter *ControlFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
WITH ctrl AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.tenant_id,
|
||||
c.search_vector
|
||||
FROM
|
||||
controls c
|
||||
INNER JOIN
|
||||
controls_snapshots cs ON c.id = cs.control_id
|
||||
WHERE
|
||||
cs.snapshot_id = @snapshot_id
|
||||
)
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
ctrl
|
||||
WHERE %s
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"snapshot_id": snapshotID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *Controls) LoadBySnapshotID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -99,74 +99,3 @@ WHERE
|
||||
return err
|
||||
}
|
||||
|
||||
func (cas *ControlAudits) LoadByControlID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
controlID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
control_id,
|
||||
audit_id,
|
||||
created_at
|
||||
FROM
|
||||
controls_audits
|
||||
WHERE
|
||||
%s
|
||||
AND control_id = @control_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"control_id": controlID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query control_audits: %w", err)
|
||||
}
|
||||
|
||||
controlAudits, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ControlAudit])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect control_audits: %w", err)
|
||||
}
|
||||
|
||||
*cas = controlAudits
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cas *ControlAudits) LoadByAuditID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
auditID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
control_id,
|
||||
audit_id,
|
||||
created_at
|
||||
FROM
|
||||
controls_audits
|
||||
WHERE
|
||||
%s
|
||||
AND audit_id = @audit_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"audit_id": auditID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query control_audits: %w", err)
|
||||
}
|
||||
|
||||
controlAudits, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ControlAudit])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect control_audits: %w", err)
|
||||
}
|
||||
|
||||
*cas = controlAudits
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,43 +100,6 @@ WHERE
|
||||
return err
|
||||
}
|
||||
|
||||
func (cms *ControlMeasures) LoadByMeasureID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
measureID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
control_id,
|
||||
measure_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
FROM
|
||||
controls_measures
|
||||
WHERE
|
||||
%s
|
||||
AND measure_id = @measure_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"measure_id": measureID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query control_measures: %w", err)
|
||||
}
|
||||
|
||||
controlMeasures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ControlMeasure])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect control_measures: %w", err)
|
||||
}
|
||||
|
||||
*cms = controlMeasures
|
||||
return nil
|
||||
}
|
||||
|
||||
type ControlWithRisk struct {
|
||||
ControlID gid.GID `db:"control_id"`
|
||||
}
|
||||
|
||||
@@ -95,78 +95,6 @@ WHERE
|
||||
return err
|
||||
}
|
||||
|
||||
func (cos *ControlObligations) LoadByControlID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
controlID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
control_id,
|
||||
obligation_id,
|
||||
created_at
|
||||
FROM
|
||||
controls_obligations
|
||||
WHERE
|
||||
%s
|
||||
AND control_id = @control_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"control_id": controlID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query control_obligations: %w", err)
|
||||
}
|
||||
|
||||
controlObligations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ControlObligation])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect control_obligations: %w", err)
|
||||
}
|
||||
|
||||
*cos = controlObligations
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cos *ControlObligations) LoadByObligationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
obligationID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
control_id,
|
||||
obligation_id,
|
||||
created_at
|
||||
FROM
|
||||
controls_obligations
|
||||
WHERE
|
||||
%s
|
||||
AND obligation_id = @obligation_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"obligation_id": obligationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query control_obligations: %w", err)
|
||||
}
|
||||
|
||||
controlObligations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ControlObligation])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect control_obligations: %w", err)
|
||||
}
|
||||
|
||||
*cos = controlObligations
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cos *ControlObligations) CountByControlID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -99,74 +99,3 @@ WHERE
|
||||
return err
|
||||
}
|
||||
|
||||
func (css *ControlSnapshots) LoadByControlID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
controlID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
control_id,
|
||||
snapshot_id,
|
||||
created_at
|
||||
FROM
|
||||
controls_snapshots
|
||||
WHERE
|
||||
%s
|
||||
AND control_id = @control_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"control_id": controlID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query controls_snapshots: %w", err)
|
||||
}
|
||||
|
||||
controlSnapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ControlSnapshot])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect controls_snapshots: %w", err)
|
||||
}
|
||||
|
||||
*css = controlSnapshots
|
||||
return nil
|
||||
}
|
||||
|
||||
func (css *ControlSnapshots) LoadBySnapshotID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
snapshotID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
control_id,
|
||||
snapshot_id,
|
||||
created_at
|
||||
FROM
|
||||
controls_snapshots
|
||||
WHERE
|
||||
%s
|
||||
AND snapshot_id = @snapshot_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"snapshot_id": snapshotID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query controls_snapshots: %w", err)
|
||||
}
|
||||
|
||||
controlSnapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ControlSnapshot])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect controls_snapshots: %w", err)
|
||||
}
|
||||
|
||||
*css = controlSnapshots
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,11 +14,6 @@
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type CustomDomainVerificationStatus string
|
||||
|
||||
const (
|
||||
@@ -26,41 +21,3 @@ const (
|
||||
CustomDomainVerificationStatusVerified CustomDomainVerificationStatus = "VERIFIED"
|
||||
CustomDomainVerificationStatusFailed CustomDomainVerificationStatus = "FAILED"
|
||||
)
|
||||
|
||||
func (s CustomDomainVerificationStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *CustomDomainVerificationStatus) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case CustomDomainVerificationStatusPending.String():
|
||||
*s = CustomDomainVerificationStatusPending
|
||||
case CustomDomainVerificationStatusVerified.String():
|
||||
*s = CustomDomainVerificationStatusVerified
|
||||
case CustomDomainVerificationStatusFailed.String():
|
||||
*s = CustomDomainVerificationStatusFailed
|
||||
default:
|
||||
return fmt.Errorf("invalid CustomDomainVerificationStatus value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s CustomDomainVerificationStatus) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func (s *CustomDomainVerificationStatus) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for CustomDomainVerificationStatus, expected string got %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (s CustomDomainVerificationStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
|
||||
@@ -241,53 +241,6 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Frameworks) LoadByIDs(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
frameworkIDs []gid.GID,
|
||||
) error {
|
||||
if len(frameworkIDs) == 0 {
|
||||
*f = Frameworks{}
|
||||
return nil
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
reference_id,
|
||||
name,
|
||||
description,
|
||||
light_logo_file_id,
|
||||
dark_logo_file_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
frameworks
|
||||
WHERE
|
||||
%s
|
||||
AND id = ANY(@framework_ids)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"framework_ids": frameworkIDs}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query frameworks: %w", err)
|
||||
}
|
||||
|
||||
frameworks, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Framework])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect frameworks: %w", err)
|
||||
}
|
||||
|
||||
*f = frameworks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f Framework) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -54,84 +53,6 @@ func (i Identity) CursorKey(orderBy IdentityOrderField) page.CursorKey {
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (i *Identities) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[IdentityOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
email_address,
|
||||
full_name,
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
saml_subject,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
identities
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT identity_id FROM iam_memberships WHERE organization_id = @organization_id
|
||||
)
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query identities: %w", err)
|
||||
}
|
||||
|
||||
identities, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Identity])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect identities: %w", err)
|
||||
}
|
||||
|
||||
*i = identities
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identities) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
identities
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT identity_id FROM iam_memberships WHERE organization_id = @organization_id AND %s
|
||||
)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count identities: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access identities across all tenants for authentication purposes.
|
||||
func (i *Identity) LoadByEmail(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -17,7 +17,6 @@ package coredata
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -36,46 +35,6 @@ type (
|
||||
MeetingAttendees []*MeetingAttendee
|
||||
)
|
||||
|
||||
func (ma *MeetingAttendees) LoadByMeetingID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
meetingID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
meeting_id,
|
||||
attendee_profile_id,
|
||||
organization_id,
|
||||
created_at
|
||||
FROM
|
||||
meeting_attendees
|
||||
WHERE
|
||||
%s
|
||||
AND meeting_id = @meeting_id
|
||||
ORDER BY
|
||||
created_at ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"meeting_id": meetingID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query meeting attendees: %w", err)
|
||||
}
|
||||
|
||||
attendees, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MeetingAttendee])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect meeting attendees: %w", err)
|
||||
}
|
||||
|
||||
*ma = attendees
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ma *MeetingAttendees) Merge(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -183,169 +183,6 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organizations) LoadAllByIdentityID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
identityID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH identity_org AS (
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
iam_memberships
|
||||
WHERE
|
||||
identity_id = @identity_id
|
||||
)
|
||||
SELECT
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organizations
|
||||
INNER JOIN
|
||||
identity_org ON organizations.id = identity_org.organization_id
|
||||
ORDER BY
|
||||
name ASC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"identity_id": identityID}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect organizations: %w", err)
|
||||
}
|
||||
|
||||
*o = organizations
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organizations) LoadAllByIdentityIDWithRole(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
identityID gid.GID,
|
||||
role MembershipRole,
|
||||
) error {
|
||||
q := `
|
||||
WITH identity_org AS (
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
iam_memberships
|
||||
WHERE
|
||||
identity_id = @identity_id
|
||||
AND role = @role
|
||||
)
|
||||
SELECT
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organizations
|
||||
INNER JOIN
|
||||
identity_org ON organizations.id = identity_org.organization_id
|
||||
ORDER BY
|
||||
name ASC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"identity_id": identityID,
|
||||
"role": role,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect organizations: %w", err)
|
||||
}
|
||||
|
||||
*o = organizations
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organizations) LoadAllByPersonalAPIKeyID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
personalAPIKeyID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH personal_api_key_org AS (
|
||||
SELECT
|
||||
am.organization_id
|
||||
FROM
|
||||
iam_personal_api_key_memberships akm
|
||||
INNER JOIN
|
||||
iam_memberships am ON akm.membership_id = am.id
|
||||
WHERE
|
||||
akm.personal_api_key_id = @personal_api_key_id
|
||||
)
|
||||
SELECT
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organizations
|
||||
INNER JOIN
|
||||
personal_api_key_org ON organizations.id = personal_api_key_org.organization_id
|
||||
ORDER BY
|
||||
name ASC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"personal_api_key_id": personalAPIKeyID}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect organizations: %w", err)
|
||||
}
|
||||
|
||||
*o = organizations
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organization) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -509,49 +346,3 @@ LIMIT 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organizations) BatchLoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationIDs []gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organizations
|
||||
WHERE
|
||||
%s
|
||||
AND id = ANY(@organization_ids)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_ids": organizationIDs}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect organizations: %w", err)
|
||||
}
|
||||
|
||||
*o = organizations
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,63 +55,6 @@ VALUES (@id, @organization_id, @created_at, @expires_at)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLRequest) Load(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
requestID string,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, created_at, expires_at
|
||||
FROM iam_saml_requests
|
||||
WHERE id = @id AND organization_id = @organization_id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": requestID,
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query saml_requests: %w", err)
|
||||
}
|
||||
|
||||
req, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRequest])
|
||||
if err == pgx.ErrNoRows {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect saml_request: %w", err)
|
||||
}
|
||||
|
||||
*s = req
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLRequest) IsExpired(now time.Time) bool {
|
||||
return now.After(s.ExpiresAt) || now.Equal(s.ExpiresAt)
|
||||
}
|
||||
|
||||
func (s *SAMLRequest) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
query := `
|
||||
DELETE FROM iam_saml_requests
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, query, pgx.NamedArgs{"id": s.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete saml_request: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadValidRequestIDsForOrganization(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -28,17 +28,6 @@ type (
|
||||
EncryptionKey [32]byte
|
||||
)
|
||||
|
||||
func NewEncryptionKey(key string) (EncryptionKey, error) {
|
||||
if len(key) != 32 {
|
||||
return EncryptionKey{}, fmt.Errorf("key must be 32 bytes for AES-256")
|
||||
}
|
||||
|
||||
var encryptionKey EncryptionKey
|
||||
copy(encryptionKey[:], key)
|
||||
|
||||
return encryptionKey, nil
|
||||
}
|
||||
|
||||
func (k EncryptionKey) Bytes() []byte {
|
||||
return k[:]
|
||||
}
|
||||
|
||||
@@ -34,22 +34,6 @@ func NewTenantID() TenantID {
|
||||
return defaultTenantGenerator.NewTenantID()
|
||||
}
|
||||
|
||||
// ParseTenantID parses a string representation into a TenantID
|
||||
func ParseTenantID(s string) (TenantID, error) {
|
||||
var id TenantID
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return NilTenant, fmt.Errorf("invalid tenant ID encoding: %w", err)
|
||||
}
|
||||
|
||||
if len(decoded) != len(id) {
|
||||
return NilTenant, fmt.Errorf("invalid tenant ID length: got %d, want %d", len(decoded), len(id))
|
||||
}
|
||||
|
||||
copy(id[:], decoded)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// newTenantGenerator creates a new generator with machine-specific components
|
||||
func newTenantGenerator() *tenantGenerator {
|
||||
g := &tenantGenerator{
|
||||
|
||||
@@ -49,11 +49,6 @@ type (
|
||||
InvitationToken string
|
||||
}
|
||||
|
||||
LoadOrCreateIdentityRequest struct {
|
||||
Email mail.Addr
|
||||
FullName string
|
||||
}
|
||||
|
||||
CreateIdentityWithPasswordRequest struct {
|
||||
Email mail.Addr
|
||||
Password string
|
||||
@@ -115,14 +110,6 @@ func (req ChangePasswordRequest) Validate() error {
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (req LoadOrCreateIdentityRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(req.FullName, "fullName", validator.NotEmpty(), validator.MinLen(1), validator.MaxLen(255))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (req CreateIdentityWithPasswordRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
|
||||
@@ -313,19 +313,6 @@ func (e ErrInvalidCredentials) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
type ErrInvitationNotDeleted struct {
|
||||
InvitationID gid.GID
|
||||
Status string
|
||||
}
|
||||
|
||||
func NewInvitationNotDeletedError(invitationID gid.GID, status string) error {
|
||||
return &ErrInvitationNotDeleted{InvitationID: invitationID, Status: status}
|
||||
}
|
||||
|
||||
func (e ErrInvitationNotDeleted) Error() string {
|
||||
return fmt.Sprintf("cannot delete invitation %q in %q status", e.InvitationID, e.Status)
|
||||
}
|
||||
|
||||
type ErrPasswordAuthenticationRequired struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Action represents a permission action in the format "service:resource:operation"
|
||||
// Examples: "iam:identity:get", "documents:document:write", "risks:risk:delete"
|
||||
type Action string
|
||||
|
||||
// ActionDefinition provides metadata about an action for documentation and validation.
|
||||
type ActionDefinition struct {
|
||||
Action Action
|
||||
Service string // e.g., "iam", "documents", "risks"
|
||||
Resource string // e.g., "identity", "document", "risk"
|
||||
Operation string // e.g., "get", "list", "create", "update", "delete"
|
||||
Description string
|
||||
}
|
||||
|
||||
// ActionRegistry holds all registered actions and provides lookup/validation.
|
||||
type ActionRegistry struct {
|
||||
actions map[Action]ActionDefinition
|
||||
}
|
||||
|
||||
// NewActionRegistry creates a new empty action registry.
|
||||
func NewActionRegistry() *ActionRegistry {
|
||||
return &ActionRegistry{
|
||||
actions: make(map[Action]ActionDefinition),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds an action definition to the registry.
|
||||
// Returns an error if the action is already registered.
|
||||
func (r *ActionRegistry) Register(def ActionDefinition) error {
|
||||
if _, exists := r.actions[def.Action]; exists {
|
||||
return fmt.Errorf("action %q already registered", def.Action)
|
||||
}
|
||||
|
||||
// Validate action format
|
||||
if err := validateActionFormat(def.Action); err != nil {
|
||||
return fmt.Errorf("invalid action format: %w", err)
|
||||
}
|
||||
|
||||
r.actions[def.Action] = def
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustRegister is like Register but panics on error.
|
||||
// Useful for setting up registries in application startup.
|
||||
func (r *ActionRegistry) MustRegister(def ActionDefinition) {
|
||||
if err := r.Register(def); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the definition for an action, or false if not found.
|
||||
func (r *ActionRegistry) Get(action Action) (ActionDefinition, bool) {
|
||||
def, ok := r.actions[action]
|
||||
return def, ok
|
||||
}
|
||||
|
||||
// Exists checks if an action is registered.
|
||||
func (r *ActionRegistry) Exists(action Action) bool {
|
||||
_, ok := r.actions[action]
|
||||
return ok
|
||||
}
|
||||
|
||||
// All returns all registered action definitions.
|
||||
func (r *ActionRegistry) All() []ActionDefinition {
|
||||
result := make([]ActionDefinition, 0, len(r.actions))
|
||||
for _, def := range r.actions {
|
||||
result = append(result, def)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ByService returns all actions for a given service.
|
||||
func (r *ActionRegistry) ByService(service string) []ActionDefinition {
|
||||
var result []ActionDefinition
|
||||
for _, def := range r.actions {
|
||||
if def.Service == service {
|
||||
result = append(result, def)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateActionFormat ensures action follows "service:resource:operation" format.
|
||||
func validateActionFormat(action Action) error {
|
||||
parts := strings.Split(string(action), ":")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("action must have format 'service:resource:operation', got %q", action)
|
||||
}
|
||||
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return fmt.Errorf("action part %d is empty in %q", i, action)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseAction extracts service, resource, and operation from an action string.
|
||||
func ParseAction(action Action) (service, resource, operation string, err error) {
|
||||
parts := strings.Split(string(action), ":")
|
||||
if len(parts) != 3 {
|
||||
return "", "", "", fmt.Errorf("invalid action format: %q", action)
|
||||
}
|
||||
return parts[0], parts[1], parts[2], nil
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActionRegistry_Register(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
def ActionDefinition
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid action",
|
||||
def: ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format - missing parts",
|
||||
def: ActionDefinition{
|
||||
Action: "iam:identity",
|
||||
Service: "iam",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - empty part",
|
||||
def: ActionDefinition{
|
||||
Action: "iam::get",
|
||||
Service: "iam",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
def: ActionDefinition{
|
||||
Action: "iam:identity:get:extra",
|
||||
Service: "iam",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
err := r.Register(tt.def)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Register() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_DuplicateRegistration(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
def := ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity",
|
||||
}
|
||||
|
||||
// First registration should succeed
|
||||
if err := r.Register(def); err != nil {
|
||||
t.Fatalf("First registration failed: %v", err)
|
||||
}
|
||||
|
||||
// Second registration should fail
|
||||
if err := r.Register(def); err == nil {
|
||||
t.Error("Expected error for duplicate registration, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_Get(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
def := ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity",
|
||||
}
|
||||
r.MustRegister(def)
|
||||
|
||||
// Get existing action
|
||||
got, ok := r.Get("iam:identity:get")
|
||||
if !ok {
|
||||
t.Error("Expected to find action")
|
||||
}
|
||||
if got.Action != def.Action {
|
||||
t.Errorf("Got action %v, want %v", got.Action, def.Action)
|
||||
}
|
||||
|
||||
// Get non-existing action
|
||||
_, ok = r.Get("iam:identity:delete")
|
||||
if ok {
|
||||
t.Error("Expected not to find action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_Exists(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
r.MustRegister(ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
})
|
||||
|
||||
if !r.Exists("iam:identity:get") {
|
||||
t.Error("Expected action to exist")
|
||||
}
|
||||
|
||||
if r.Exists("iam:identity:delete") {
|
||||
t.Error("Expected action not to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_ByService(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
r.MustRegister(ActionDefinition{Action: "iam:identity:get", Service: "iam", Resource: "identity", Operation: "get"})
|
||||
r.MustRegister(ActionDefinition{Action: "iam:identity:update", Service: "iam", Resource: "identity", Operation: "update"})
|
||||
r.MustRegister(ActionDefinition{Action: "documents:document:read", Service: "documents", Resource: "document", Operation: "read"})
|
||||
|
||||
iamActions := r.ByService("iam")
|
||||
if len(iamActions) != 2 {
|
||||
t.Errorf("Expected 2 IAM actions, got %d", len(iamActions))
|
||||
}
|
||||
|
||||
docActions := r.ByService("documents")
|
||||
if len(docActions) != 1 {
|
||||
t.Errorf("Expected 1 documents action, got %d", len(docActions))
|
||||
}
|
||||
|
||||
unknownActions := r.ByService("unknown")
|
||||
if len(unknownActions) != 0 {
|
||||
t.Errorf("Expected 0 unknown actions, got %d", len(unknownActions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
action Action
|
||||
wantSvc string
|
||||
wantRes string
|
||||
wantOp string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
action: "iam:identity:get",
|
||||
wantSvc: "iam",
|
||||
wantRes: "identity",
|
||||
wantOp: "get",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
action: "documents:document:read",
|
||||
wantSvc: "documents",
|
||||
wantRes: "document",
|
||||
wantOp: "read",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
action: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
action: "invalid:action",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.action), func(t *testing.T) {
|
||||
svc, res, op, err := ParseAction(tt.action)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if svc != tt.wantSvc {
|
||||
t.Errorf("service = %v, want %v", svc, tt.wantSvc)
|
||||
}
|
||||
if res != tt.wantRes {
|
||||
t.Errorf("resource = %v, want %v", res, tt.wantRes)
|
||||
}
|
||||
if op != tt.wantOp {
|
||||
t.Errorf("operation = %v, want %v", op, tt.wantOp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccessDenied is returned when access is explicitly denied.
|
||||
ErrAccessDenied = errors.New("access denied")
|
||||
|
||||
// ErrNoMatchingPolicy is returned when no policy grants access (implicit deny).
|
||||
ErrNoMatchingPolicy = errors.New("no matching policy")
|
||||
)
|
||||
|
||||
// AccessDeniedError provides detailed information about why access was denied.
|
||||
type AccessDeniedError struct {
|
||||
Principal gid.GID
|
||||
Resource gid.GID
|
||||
Action string
|
||||
Reason string
|
||||
Statement *Statement // The statement that denied access (if explicit deny)
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Error() string {
|
||||
if e.Statement != nil && e.Statement.SID != "" {
|
||||
return fmt.Sprintf("access denied: principal %s cannot perform %s on %s (denied by %s)",
|
||||
e.Principal, e.Action, e.Resource, e.Statement.SID)
|
||||
}
|
||||
return fmt.Sprintf("access denied: principal %s cannot perform %s on %s: %s",
|
||||
e.Principal, e.Action, e.Resource, e.Reason)
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Unwrap() error {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Authorizer evaluates policies to authorize actions.
|
||||
type Authorizer struct {
|
||||
evaluator *Evaluator
|
||||
registry *ActionRegistry
|
||||
}
|
||||
|
||||
// NewAuthorizer creates a new authorizer with the given action registry.
|
||||
func NewAuthorizer(registry *ActionRegistry) *Authorizer {
|
||||
return &Authorizer{
|
||||
evaluator: NewEvaluator(),
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizeParams contains all parameters for an authorization check.
|
||||
type AuthorizeParams struct {
|
||||
// Principal is the actor requesting access.
|
||||
Principal gid.GID
|
||||
|
||||
// Resource is the target resource.
|
||||
Resource gid.GID
|
||||
|
||||
// Action is the operation being performed.
|
||||
Action string
|
||||
|
||||
// Policies are the policies to evaluate (typically role-based + self-manage).
|
||||
Policies []*Policy
|
||||
|
||||
// ResourceAttributes provides additional attributes about the resource
|
||||
// for condition evaluation (e.g., owner_id, tenant_id).
|
||||
ResourceAttributes map[string]string
|
||||
}
|
||||
|
||||
// Authorize checks if the action is allowed based on the provided policies.
|
||||
// Returns nil if allowed, or an error describing why access was denied.
|
||||
func (a *Authorizer) Authorize(params AuthorizeParams) error {
|
||||
// Validate action exists in registry (optional - can be disabled for flexibility)
|
||||
if a.registry != nil && !a.registry.Exists(Action(params.Action)) {
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "unknown action",
|
||||
}
|
||||
}
|
||||
|
||||
// Build condition context
|
||||
conditionCtx := ConditionContext{
|
||||
Principal: map[string]string{
|
||||
"id": params.Principal.String(),
|
||||
},
|
||||
Resource: map[string]string{
|
||||
"id": params.Resource.String(),
|
||||
},
|
||||
}
|
||||
|
||||
// Add resource attributes to context
|
||||
maps.Copy(conditionCtx.Resource, params.ResourceAttributes)
|
||||
|
||||
// Build authorization request
|
||||
req := AuthorizationRequest{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
ConditionContext: conditionCtx,
|
||||
}
|
||||
|
||||
// Evaluate policies
|
||||
result := a.evaluator.Evaluate(req, params.Policies)
|
||||
|
||||
switch result.Decision {
|
||||
case DecisionAllow:
|
||||
return nil
|
||||
|
||||
case DecisionDeny:
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "explicitly denied",
|
||||
Statement: result.MatchedStatement,
|
||||
}
|
||||
|
||||
case DecisionNoMatch:
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "no policy allows this action",
|
||||
}
|
||||
|
||||
default:
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "unexpected evaluation result",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowed is a convenience method that returns true if access is allowed.
|
||||
func (a *Authorizer) IsAllowed(params AuthorizeParams) bool {
|
||||
return a.Authorize(params) == nil
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorizer_Authorize(t *testing.T) {
|
||||
// Create a simple registry for testing
|
||||
registry := NewActionRegistry()
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:resource:get",
|
||||
Service: "test",
|
||||
Resource: "resource",
|
||||
Operation: "get",
|
||||
Description: "Get resource",
|
||||
})
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:resource:update",
|
||||
Service: "test",
|
||||
Resource: "resource",
|
||||
Operation: "update",
|
||||
Description: "Update resource",
|
||||
})
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:resource:delete",
|
||||
Service: "test",
|
||||
Resource: "resource",
|
||||
Operation: "delete",
|
||||
Description: "Delete resource",
|
||||
})
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:other:get",
|
||||
Service: "test",
|
||||
Resource: "other",
|
||||
Operation: "get",
|
||||
Description: "Get other",
|
||||
})
|
||||
|
||||
authorizer := NewAuthorizer(registry)
|
||||
|
||||
policies := []*Policy{
|
||||
NewPolicy("test", "Test",
|
||||
Allow("test:resource:get", "test:resource:update"),
|
||||
Deny("test:resource:delete"),
|
||||
),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
wantErr bool
|
||||
errType error
|
||||
}{
|
||||
{
|
||||
name: "allowed action",
|
||||
action: "test:resource:get",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "denied action",
|
||||
action: "test:resource:delete",
|
||||
wantErr: true,
|
||||
errType: ErrAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "no matching policy",
|
||||
action: "test:other:get",
|
||||
wantErr: true,
|
||||
errType: ErrAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
action: "unknown:action:here",
|
||||
wantErr: true,
|
||||
errType: ErrAccessDenied,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := authorizer.Authorize(AuthorizeParams{
|
||||
Action: tt.action,
|
||||
Policies: policies,
|
||||
ResourceAttributes: map[string]string{
|
||||
"id": "res_123",
|
||||
},
|
||||
})
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr && tt.errType != nil {
|
||||
if !errors.Is(err, tt.errType) {
|
||||
t.Errorf("Authorize() error type = %T, want %T", err, tt.errType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizer_Authorize_WithConditions(t *testing.T) {
|
||||
authorizer := NewAuthorizer(nil)
|
||||
|
||||
// Self-manage policy for testing
|
||||
selfManagePolicy := NewPolicy("self-manage", "Self Manage",
|
||||
Allow("test:identity:get", "test:identity:update").
|
||||
When(Equals("principal.id", "resource.id")),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
resourceAttributes map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "condition not satisfied - GID won't match string",
|
||||
action: "test:identity:get",
|
||||
resourceAttributes: map[string]string{
|
||||
"id": "user_123",
|
||||
},
|
||||
wantErr: true, // Will fail because principal GID won't match string
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := authorizer.Authorize(AuthorizeParams{
|
||||
Action: tt.action,
|
||||
Policies: []*Policy{selfManagePolicy},
|
||||
ResourceAttributes: tt.resourceAttributes,
|
||||
})
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizer_Authorize_WithoutRegistry(t *testing.T) {
|
||||
// Authorizer without registry should not validate actions
|
||||
authorizer := NewAuthorizer(nil)
|
||||
|
||||
policies := []*Policy{
|
||||
NewPolicy("test", "Test", Allow("custom:action:here")),
|
||||
}
|
||||
|
||||
err := authorizer.Authorize(AuthorizeParams{
|
||||
Action: "custom:action:here",
|
||||
Policies: policies,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for custom action without registry, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizer_IsAllowed(t *testing.T) {
|
||||
authorizer := NewAuthorizer(nil)
|
||||
|
||||
allowPolicy := NewPolicy("test", "Test", Allow("test:resource:get"))
|
||||
denyPolicy := NewPolicy("test", "Test", Deny("test:resource:delete"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
policies []*Policy
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "allowed",
|
||||
action: "test:resource:get",
|
||||
policies: []*Policy{allowPolicy},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "denied",
|
||||
action: "test:resource:delete",
|
||||
policies: []*Policy{denyPolicy},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
action: "test:resource:update",
|
||||
policies: []*Policy{allowPolicy},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := authorizer.IsAllowed(AuthorizeParams{
|
||||
Action: tt.action,
|
||||
Policies: tt.policies,
|
||||
})
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("IsAllowed() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessDeniedError(t *testing.T) {
|
||||
t.Run("error message without statement", func(t *testing.T) {
|
||||
err := &AccessDeniedError{
|
||||
Action: "test:resource:delete",
|
||||
Reason: "no policy allows this action",
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if msg == "" {
|
||||
t.Error("Expected non-empty error message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error message with statement SID", func(t *testing.T) {
|
||||
err := &AccessDeniedError{
|
||||
Action: "test:resource:delete",
|
||||
Reason: "explicitly denied",
|
||||
Statement: &Statement{
|
||||
SID: "deny-delete",
|
||||
},
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if msg == "" {
|
||||
t.Error("Expected non-empty error message")
|
||||
}
|
||||
// Should contain the SID
|
||||
if !contains(msg, "deny-delete") {
|
||||
t.Errorf("Expected error message to contain SID, got %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwrap returns ErrAccessDenied", func(t *testing.T) {
|
||||
err := &AccessDeniedError{
|
||||
Action: "test:resource:delete",
|
||||
Reason: "no policy",
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrAccessDenied) {
|
||||
t.Error("Expected error to unwrap to ErrAccessDenied")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr, 0))
|
||||
}
|
||||
|
||||
func containsAt(s, substr string, start int) bool {
|
||||
for i := start; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,60 +20,6 @@ import (
|
||||
"go.probo.inc/probo/pkg/iam/policy"
|
||||
)
|
||||
|
||||
func Example_definingActions() {
|
||||
// Create an action registry
|
||||
registry := policy.NewActionRegistry()
|
||||
|
||||
// Register actions for the IAM service
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity details",
|
||||
})
|
||||
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "iam:identity:update",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "update",
|
||||
Description: "Update identity",
|
||||
})
|
||||
|
||||
// Register actions for the documents service
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "documents:document:read",
|
||||
Service: "documents",
|
||||
Resource: "document",
|
||||
Operation: "read",
|
||||
Description: "Read a document",
|
||||
})
|
||||
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "documents:document:write",
|
||||
Service: "documents",
|
||||
Resource: "document",
|
||||
Operation: "write",
|
||||
Description: "Create or update a document",
|
||||
})
|
||||
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "documents:document:delete",
|
||||
Service: "documents",
|
||||
Resource: "document",
|
||||
Operation: "delete",
|
||||
Description: "Delete a document",
|
||||
})
|
||||
|
||||
// List all actions for a service
|
||||
docActions := registry.ByService("documents")
|
||||
fmt.Printf("Documents service has %d actions\n", len(docActions))
|
||||
|
||||
// Output:
|
||||
// Documents service has 3 actions
|
||||
}
|
||||
|
||||
func Example_definingPolicies() {
|
||||
// Define a viewer policy - can read everything
|
||||
viewerPolicy := policy.NewPolicy("viewer", "Viewer Policy",
|
||||
|
||||
@@ -107,27 +107,3 @@ func NotEquals(key string, values ...string) Condition {
|
||||
}
|
||||
}
|
||||
|
||||
// In creates an In condition.
|
||||
func In(key string, values ...string) Condition {
|
||||
return Condition{
|
||||
Operator: ConditionIn,
|
||||
Key: key,
|
||||
Values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// NotIn creates a NotIn condition.
|
||||
func NotIn(key string, values ...string) Condition {
|
||||
return Condition{
|
||||
Operator: ConditionNotIn,
|
||||
Key: key,
|
||||
Values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// ForEntityType creates a resource pattern for a specific entity type.
|
||||
func ForEntityType(entityType uint16) ResourcePattern {
|
||||
return ResourcePattern{
|
||||
EntityType: &entityType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,15 +299,12 @@ func TestConditionHelpers(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("In helper", func(t *testing.T) {
|
||||
c := In("principal.role", "admin", "owner")
|
||||
if c.Operator != ConditionIn {
|
||||
t.Errorf("Expected ConditionIn, got %v", c.Operator)
|
||||
t.Run("NotIn condition", func(t *testing.T) {
|
||||
c := Condition{
|
||||
Operator: ConditionNotIn,
|
||||
Key: "principal.role",
|
||||
Values: []string{"guest"},
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotIn helper", func(t *testing.T) {
|
||||
c := NotIn("principal.role", "guest")
|
||||
if c.Operator != ConditionNotIn {
|
||||
t.Errorf("Expected ConditionNotIn, got %v", c.Operator)
|
||||
}
|
||||
|
||||
@@ -31,25 +31,11 @@ type (
|
||||
provider provider.Provider
|
||||
scimClient *scimclient.Client
|
||||
excludedUserNames []string
|
||||
forceUpdate bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
Option func(*Bridge)
|
||||
)
|
||||
|
||||
func WithDryRun(dryRun bool) Option {
|
||||
return func(s *Bridge) {
|
||||
s.dryRun = dryRun
|
||||
}
|
||||
}
|
||||
|
||||
func WithForceUpdate(forceUpdate bool) Option {
|
||||
return func(s *Bridge) {
|
||||
s.forceUpdate = forceUpdate
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedUserNames(excludedUserNames []string) Option {
|
||||
return func(s *Bridge) {
|
||||
s.excludedUserNames = excludedUserNames
|
||||
@@ -96,16 +82,13 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
|
||||
existingSCIM, exists := scimUsersByEmail[email]
|
||||
if !exists {
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.CreateUser(ctx, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot create user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.CreateUser(ctx, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot create user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
created++
|
||||
} else {
|
||||
needsUpdate := s.forceUpdate ||
|
||||
existingSCIM.Active != pu.Active ||
|
||||
needsUpdate := existingSCIM.Active != pu.Active ||
|
||||
existingSCIM.DisplayName != pu.DisplayName ||
|
||||
existingSCIM.Title != pu.Title ||
|
||||
existingSCIM.GivenName != pu.GivenName ||
|
||||
@@ -120,11 +103,9 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
existingSCIM.PreferredLanguage != pu.PreferredLanguage
|
||||
|
||||
if needsUpdate {
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.UpdateUser(ctx, existingSCIM.ID, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot update user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.UpdateUser(ctx, existingSCIM.ID, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot update user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
} else {
|
||||
@@ -139,11 +120,9 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
}
|
||||
|
||||
if s.isExcluded(email) {
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.DeleteUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot delete user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.DeleteUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot delete user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
continue
|
||||
@@ -153,11 +132,9 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.DeactivateUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot deactivate user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.DeactivateUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot deactivate user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
deactivated++
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -36,18 +35,6 @@ func NewSessionService(svc *Service) *SessionService {
|
||||
return &SessionService{Service: svc}
|
||||
}
|
||||
|
||||
type (
|
||||
RevokeAllSessionsRequest struct {
|
||||
CurrentSessionID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
func (req RevokeAllSessionsRequest) Validate() error {
|
||||
v := validator.New()
|
||||
v.Check(req.CurrentSessionID, "current_session_id", validator.GID(coredata.SessionEntityType))
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s SessionService) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
|
||||
var (
|
||||
session = &coredata.Session{}
|
||||
|
||||
@@ -50,20 +50,6 @@ func (ccdr *CreateCustomDomainRequest) Validate() error {
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func NewCustomDomainService(
|
||||
svc *TenantService,
|
||||
acmeService *certmanager.ACMEService,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
logger *log.Logger,
|
||||
) *CustomDomainService {
|
||||
return &CustomDomainService{
|
||||
svc: svc,
|
||||
acmeService: acmeService,
|
||||
encryptionKey: encryptionKey,
|
||||
logger: logger.Named("custom_domain"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CustomDomainService) CreateCustomDomain(
|
||||
ctx context.Context,
|
||||
req CreateCustomDomainRequest,
|
||||
|
||||
@@ -36,10 +36,6 @@ type (
|
||||
fileValidator *filevalidation.FileValidator
|
||||
}
|
||||
|
||||
CreateOrganizationRequest struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
UpdateOrganizationRequest struct {
|
||||
ID gid.GID
|
||||
Name *string
|
||||
@@ -57,14 +53,6 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func (cor *CreateOrganizationRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cor.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (uor *UpdateOrganizationRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
|
||||
@@ -30,21 +30,12 @@ type SnapshotService struct {
|
||||
svc *TenantService
|
||||
}
|
||||
|
||||
type (
|
||||
CreateSnapshotRequest struct {
|
||||
OrganizationID gid.GID
|
||||
Name string
|
||||
Description *string
|
||||
Type coredata.SnapshotsType
|
||||
}
|
||||
|
||||
UpdateSnapshotRequest struct {
|
||||
ID gid.GID
|
||||
Name *string
|
||||
Description **string
|
||||
Type *coredata.SnapshotsType
|
||||
}
|
||||
)
|
||||
type CreateSnapshotRequest struct {
|
||||
OrganizationID gid.GID
|
||||
Name string
|
||||
Description *string
|
||||
Type coredata.SnapshotsType
|
||||
}
|
||||
|
||||
func (csr *CreateSnapshotRequest) Validate() error {
|
||||
v := validator.New()
|
||||
@@ -57,17 +48,6 @@ func (csr *CreateSnapshotRequest) Validate() error {
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (usr *UpdateSnapshotRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(usr.ID, "id", validator.Required(), validator.GID(coredata.SnapshotEntityType))
|
||||
v.Check(usr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(usr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(usr.Type, "type", validator.OneOfSlice(coredata.SnapshotsTypes()))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s *SnapshotService) Get(
|
||||
ctx context.Context,
|
||||
snapshotID gid.GID,
|
||||
|
||||
@@ -58,14 +58,6 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func (ctcar *CreateTrustCenterAccessRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(ctcar.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (utcar *UpdateTrustCenterAccessRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
|
||||
@@ -70,7 +70,3 @@ func (sr *SafeRedirect) Redirect(w http.ResponseWriter, r *http.Request, redirec
|
||||
http.Redirect(w, r, safeURL, statusCode)
|
||||
}
|
||||
|
||||
func (sr *SafeRedirect) RedirectFromQuery(w http.ResponseWriter, r *http.Request, paramName, fallbackURL string, statusCode int) {
|
||||
redirectURL := r.URL.Query().Get(paramName)
|
||||
sr.Redirect(w, r, redirectURL, fallbackURL, statusCode)
|
||||
}
|
||||
|
||||
@@ -255,96 +255,3 @@ func TestSafeRedirect_Redirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirect_RedirectFromQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedHost string
|
||||
queryParam string
|
||||
queryValue string
|
||||
fallbackURL string
|
||||
expectedStatus int
|
||||
expectedURL string
|
||||
}{
|
||||
{
|
||||
name: "safe continue param",
|
||||
allowedHost: "example.com",
|
||||
queryParam: "continue",
|
||||
queryValue: "/dashboard",
|
||||
fallbackURL: "/home",
|
||||
expectedStatus: http.StatusFound,
|
||||
expectedURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "unsafe continue param",
|
||||
allowedHost: "example.com",
|
||||
queryParam: "continue",
|
||||
queryValue: "https://evil.com/phishing",
|
||||
fallbackURL: "/home",
|
||||
expectedStatus: http.StatusFound,
|
||||
expectedURL: "/home",
|
||||
},
|
||||
{
|
||||
name: "missing continue param",
|
||||
allowedHost: "example.com",
|
||||
queryParam: "continue",
|
||||
queryValue: "",
|
||||
fallbackURL: "/home",
|
||||
expectedStatus: http.StatusFound,
|
||||
expectedURL: "/home",
|
||||
},
|
||||
{
|
||||
name: "different query param name",
|
||||
allowedHost: "example.com",
|
||||
queryParam: "next",
|
||||
queryValue: "/profile",
|
||||
fallbackURL: "/home",
|
||||
expectedStatus: http.StatusFound,
|
||||
expectedURL: "/profile",
|
||||
},
|
||||
{
|
||||
name: "double slash attack in query",
|
||||
allowedHost: "example.com",
|
||||
queryParam: "continue",
|
||||
queryValue: "//evil.com/phishing",
|
||||
fallbackURL: "/home",
|
||||
expectedStatus: http.StatusFound,
|
||||
expectedURL: "/home",
|
||||
},
|
||||
{
|
||||
name: "slash-backslash attack in query",
|
||||
allowedHost: "example.com",
|
||||
queryParam: "continue",
|
||||
queryValue: "/\\evil.com/phishing",
|
||||
fallbackURL: "/home",
|
||||
expectedStatus: http.StatusFound,
|
||||
expectedURL: "/home",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sr := saferedirect.SafeRedirect{
|
||||
AllowedHost: tt.allowedHost,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
url := "http://test.com"
|
||||
if tt.queryValue != "" {
|
||||
url = "http://test.com?" + tt.queryParam + "=" + tt.queryValue
|
||||
}
|
||||
r := httptest.NewRequest("GET", url, nil)
|
||||
|
||||
sr.RedirectFromQuery(w, r, tt.queryParam, tt.fallbackURL, tt.expectedStatus)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("RedirectFromQuery() status = %v, want %v", w.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != tt.expectedURL {
|
||||
t.Errorf("RedirectFromQuery() location = %v, want %v", location, tt.expectedURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,24 +58,6 @@ type Config struct {
|
||||
SameSite http.SameSite
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default secure cookie configuration
|
||||
func DefaultConfig(name, secret string, secure bool) Config {
|
||||
sameSite := http.SameSiteNoneMode // None mode required for SAML (cross-site POST from IdP)
|
||||
if !secure {
|
||||
sameSite = http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
return Config{
|
||||
Name: name,
|
||||
Secret: secret,
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
Secure: secure,
|
||||
HTTPOnly: true,
|
||||
SameSite: sameSite,
|
||||
}
|
||||
}
|
||||
|
||||
// Set creates and sets a secure cookie with the given value
|
||||
func Set(w http.ResponseWriter, config Config, value string) error {
|
||||
signedValue, err := Sign(value, config.Secret)
|
||||
|
||||
@@ -24,7 +24,6 @@ type ctxKey struct{ name string }
|
||||
|
||||
var (
|
||||
compliancePageKey = &ctxKey{name: "compliance_page"}
|
||||
complianceMembershipKey = &ctxKey{name: "compliance_membership"}
|
||||
compliancePageBaseURLKey = &ctxKey{name: "compliance_page_base_url"}
|
||||
)
|
||||
|
||||
@@ -33,11 +32,6 @@ func CompliancePageFromContext(ctx context.Context) *coredata.TrustCenter {
|
||||
return page
|
||||
}
|
||||
|
||||
func ComplianceMembershipFromContext(ctx context.Context) *coredata.TrustCenterAccess {
|
||||
membership, _ := ctx.Value(complianceMembershipKey).(*coredata.TrustCenterAccess)
|
||||
return membership
|
||||
}
|
||||
|
||||
func CompliancePageBaseURLFromContext(ctx context.Context) *string {
|
||||
page, _ := ctx.Value(compliancePageBaseURLKey).(*string)
|
||||
return page
|
||||
|
||||
@@ -53,16 +53,6 @@ func NewMeetingConnection(
|
||||
}
|
||||
}
|
||||
|
||||
func NewMeetingEdges(meetings []*coredata.Meeting, orderBy coredata.MeetingOrderField) []*MeetingEdge {
|
||||
edges := make([]*MeetingEdge, len(meetings))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewMeetingEdge(meetings[i], orderBy)
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
func NewMeetingEdge(meeting *coredata.Meeting, orderBy coredata.MeetingOrderField) *MeetingEdge {
|
||||
return &MeetingEdge{
|
||||
Cursor: meeting.CursorKey(orderBy),
|
||||
|
||||
@@ -107,16 +107,6 @@ func NewTrustCenterDocumentAccessConnection(
|
||||
}
|
||||
}
|
||||
|
||||
func NewTrustCenterDocumentAccessEdges(accesses []*coredata.TrustCenterDocumentAccess, orderBy coredata.TrustCenterDocumentAccessOrderField) []*TrustCenterDocumentAccessEdge {
|
||||
edges := make([]*TrustCenterDocumentAccessEdge, len(accesses))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewTrustCenterDocumentAccessEdge(accesses[i], orderBy)
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
func NewTrustCenterDocumentAccessEdge(access *coredata.TrustCenterDocumentAccess, orderBy coredata.TrustCenterDocumentAccessOrderField) *TrustCenterDocumentAccessEdge {
|
||||
return &TrustCenterDocumentAccessEdge{
|
||||
Cursor: access.CursorKey(orderBy),
|
||||
|
||||
@@ -56,24 +56,6 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
type ctxKey struct{ name string }
|
||||
|
||||
var (
|
||||
trustCenterIDKey = &ctxKey{name: "trust_center_id"}
|
||||
)
|
||||
|
||||
func TrustCenterIDFromContext(ctx context.Context) gid.GID {
|
||||
if trustCenterID, ok := ctx.Value(trustCenterIDKey).(gid.GID); ok {
|
||||
return trustCenterID
|
||||
}
|
||||
|
||||
return gid.Nil
|
||||
}
|
||||
|
||||
func ContextWithTrustCenterID(ctx context.Context, trustCenterID gid.GID) context.Context {
|
||||
return context.WithValue(ctx, trustCenterIDKey, trustCenterID)
|
||||
}
|
||||
|
||||
func NewMux(
|
||||
logger *log.Logger,
|
||||
iamSvc *iam.Service,
|
||||
|
||||
@@ -62,10 +62,6 @@ func AssumptionRequired(ctx context.Context, err error) *gqlerror.Error {
|
||||
}
|
||||
}
|
||||
|
||||
func AssumptionRequiredf(ctx context.Context, format string, a ...any) *gqlerror.Error {
|
||||
return AssumptionRequired(ctx, fmt.Errorf(format, a...))
|
||||
}
|
||||
|
||||
func FullNameRequired(ctx context.Context, err error) *gqlerror.Error {
|
||||
return &gqlerror.Error{
|
||||
Message: err.Error(),
|
||||
@@ -104,10 +100,6 @@ func AccountAlreadyActivated(ctx context.Context, err error) *gqlerror.Error {
|
||||
}
|
||||
}
|
||||
|
||||
func AccountAlreadyActivatedf(ctx context.Context, format string, a ...any) *gqlerror.Error {
|
||||
return AccountAlreadyActivated(ctx, fmt.Errorf(format, a...))
|
||||
}
|
||||
|
||||
func Forbidden(ctx context.Context, err error) *gqlerror.Error {
|
||||
return &gqlerror.Error{
|
||||
Message: err.Error(),
|
||||
|
||||
@@ -58,3 +58,4 @@ func UnmarshalBigIntScalar(v any) (int64, error) {
|
||||
return 0, fmt.Errorf("cannot unmarshal %T into BigInt", v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,6 @@ type GzipOptions struct {
|
||||
FileTypes []string
|
||||
}
|
||||
|
||||
func DefaultGzipOptions() GzipOptions {
|
||||
return GzipOptions{
|
||||
EnableFileTypeCheck: true,
|
||||
FileTypes: []string{".js", ".css", ".html"},
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
spaFS http.FileSystem
|
||||
etags map[string]string
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestCheckEach_EmptyTypedSlice(t *testing.T) {
|
||||
t.Error("callback should not be called for empty slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error for empty slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestCheckEach_NonEmptyTypedSlice(t *testing.T) {
|
||||
t.Errorf("expected callback to be called 2 times, got %d", callCount)
|
||||
}
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestCheckEach_NilTypedSlice(t *testing.T) {
|
||||
t.Error("callback should not be called for nil slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error for nil slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestCheckEach_PointerToNonEmptySlice(t *testing.T) {
|
||||
t.Errorf("expected callback to be called 3 times, got %d", callCount)
|
||||
}
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error for pointer to slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestCheckEach_PointerToEmptySlice(t *testing.T) {
|
||||
t.Error("callback should not be called for empty slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error for pointer to empty slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func TestCheckEach_NilPointerToSlice(t *testing.T) {
|
||||
t.Error("callback should not be called for nil pointer to slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error for nil pointer to slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ func TestCheckEach_DoublePointerToSlice(t *testing.T) {
|
||||
t.Errorf("expected callback to be called 2 times, got %d", callCount)
|
||||
}
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error for double pointer to slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -184,11 +184,11 @@ func TestCheckEach_NonSliceValue(t *testing.T) {
|
||||
t.Error("callback should not be called for non-slice value")
|
||||
})
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected error for non-slice value")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
if len(errors) != 1 {
|
||||
t.Errorf("expected 1 error, got %d", len(errors))
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestDoublePointerValidation(t *testing.T) {
|
||||
|
||||
v.Check(doublePtr, "name", validator.Required(), validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
@@ -42,7 +42,7 @@ func TestDoublePointerValidation(t *testing.T) {
|
||||
|
||||
v.Check(doublePtr, "name", validator.Required(), validator.NotEmpty())
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected errors for empty string")
|
||||
}
|
||||
})
|
||||
@@ -55,7 +55,7 @@ func TestDoublePointerValidation(t *testing.T) {
|
||||
|
||||
v.Check(doublePtr, "name", validator.Required(), validator.MaxLen(10))
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected errors for string exceeding max length")
|
||||
}
|
||||
})
|
||||
@@ -66,7 +66,7 @@ func TestDoublePointerValidation(t *testing.T) {
|
||||
|
||||
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors for nil optional field, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
@@ -78,7 +78,7 @@ func TestDoublePointerValidation(t *testing.T) {
|
||||
|
||||
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors for nil optional field, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
@@ -91,7 +91,7 @@ func TestDoublePointerValidation(t *testing.T) {
|
||||
|
||||
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,11 +69,11 @@ func TestOneOf_CustomStringType(t *testing.T) {
|
||||
v.Check(tt.value, "asset_type", OneOfSlice(tt.allowed))
|
||||
|
||||
if tt.expectError {
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,11 @@ func TestOptional_WithGIDPointer(t *testing.T) {
|
||||
v.Check(tt.value, "owner_id", GID(100))
|
||||
|
||||
if tt.expectError {
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
@@ -100,14 +100,14 @@ func TestOptional_WithCustomTypePointer(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
v := New()
|
||||
v.Check(tt.value, "asset_type", OneOf("VALID", "ANOTHER"))
|
||||
v.Check(tt.value, "asset_type", OneOfSlice([]string{"VALID", "ANOTHER"}))
|
||||
|
||||
if tt.expectError {
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if v.HasErrors() {
|
||||
if v.Error() != nil {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
@@ -97,29 +96,6 @@ func (v *Validator) CheckEach(items any, field string, fn func(index int, item a
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) CheckNested(field string, fn func(v *Validator)) {
|
||||
nestedValidator := New()
|
||||
fn(nestedValidator)
|
||||
|
||||
for _, err := range nestedValidator.errors {
|
||||
prefixedErr := &ValidationError{
|
||||
Field: fmt.Sprintf("%s.%s", field, err.Field),
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
Value: err.Value,
|
||||
}
|
||||
v.errors = append(v.errors, prefixedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) HasErrors() bool {
|
||||
return len(v.errors) > 0
|
||||
}
|
||||
|
||||
func (v *Validator) Errors() ValidationErrors {
|
||||
return v.errors
|
||||
}
|
||||
|
||||
func (v *Validator) Error() error {
|
||||
if len(v.errors) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -53,61 +53,6 @@ func BenchmarkValidate_OptionalField(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_NestedStruct(b *testing.B) {
|
||||
type Address struct {
|
||||
City string
|
||||
ZipCode string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
Address Address
|
||||
}
|
||||
|
||||
user := User{
|
||||
Name: "John Doe",
|
||||
Address: Address{
|
||||
City: "New York",
|
||||
ZipCode: "10001",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(&user.Name, "name", Required())
|
||||
v.CheckNested("address", func(av *Validator) {
|
||||
av.Check(&user.Address.City, "city", Required())
|
||||
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, ""))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_ArrayValidation(b *testing.B) {
|
||||
type Item struct {
|
||||
Name string
|
||||
Price int
|
||||
}
|
||||
|
||||
items := []Item{
|
||||
{Name: "Item 1", Price: 100},
|
||||
{Name: "Item 2", Price: 200},
|
||||
{Name: "Item 3", Price: 300},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
for j, item := range items {
|
||||
v.CheckNested("items[0]", func(iv *Validator) {
|
||||
iv.Check(&item.Name, "name", Required())
|
||||
iv.Check(&item.Price, "price", Min(0))
|
||||
_ = j
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkURL(b *testing.B) {
|
||||
urlStr := "https://example.com"
|
||||
validator := URL()
|
||||
@@ -118,16 +63,6 @@ func BenchmarkURL(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUUID(b *testing.B) {
|
||||
uuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||
validator := UUID()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMinLen(b *testing.B) {
|
||||
str := "hello world"
|
||||
validator := MinLen(5)
|
||||
@@ -148,36 +83,6 @@ func BenchmarkMin(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMinFloat(b *testing.B) {
|
||||
num := 99.99
|
||||
validator := MinFloat(0.01)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&num)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMaxFloat(b *testing.B) {
|
||||
num := 50.50
|
||||
validator := MaxFloat(99.99)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&num)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRangeFloat(b *testing.B) {
|
||||
num := 50.50
|
||||
validator := RangeFloat(0.01, 99.99)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&num)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNotEmpty(b *testing.B) {
|
||||
str := "hello world"
|
||||
validator := NotEmpty()
|
||||
@@ -188,16 +93,6 @@ func BenchmarkNotEmpty(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPattern(b *testing.B) {
|
||||
zipCode := "12345"
|
||||
validator := Pattern(`^\d{5}$`, "")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&zipCode)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_WithErrors(b *testing.B) {
|
||||
email := ""
|
||||
|
||||
@@ -205,7 +100,7 @@ func BenchmarkValidate_WithErrors(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(&email, "email", Required(), NotEmpty())
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
b.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
@@ -251,73 +146,6 @@ func BenchmarkValidate_ComplexForm(b *testing.B) {
|
||||
v.Check(&user.Age, "age", Min(18), Max(120))
|
||||
v.Check(user.Website, "website", URL())
|
||||
v.Check(user.PhoneNumber, "phoneNumber", MinLen(10))
|
||||
v.Check(&user.Price, "price", MinFloat(0.01))
|
||||
|
||||
v.CheckNested("address", func(av *Validator) {
|
||||
av.Check(&user.Address.Street, "street", Required())
|
||||
av.Check(&user.Address.City, "city", Required())
|
||||
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, ""))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMinItems(b *testing.B) {
|
||||
items := []string{"a", "b", "c"}
|
||||
validator := MinItems(2)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&items)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMaxItems(b *testing.B) {
|
||||
items := []string{"a", "b", "c"}
|
||||
validator := MaxItems(5)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&items)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUniqueItems(b *testing.B) {
|
||||
items := []string{"a", "b", "c"}
|
||||
validator := UniqueItems()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&items)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAlphaNumeric(b *testing.B) {
|
||||
str := "abc123DEF456"
|
||||
validator := AlphaNumeric()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNoSpaces(b *testing.B) {
|
||||
str := "hello-world-test"
|
||||
validator := NoSpaces()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSlug(b *testing.B) {
|
||||
str := "hello-world-123"
|
||||
validator := Slug()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,47 +171,6 @@ func BenchmarkBefore(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFutureDate(b *testing.B) {
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
validator := FutureDate()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&future)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPastDate(b *testing.B) {
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
validator := PastDate()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&past)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEqualTo(b *testing.B) {
|
||||
str1 := "password"
|
||||
str2 := "password"
|
||||
validator := EqualTo(&str2)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str1)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNotEqualTo(b *testing.B) {
|
||||
str1 := "password"
|
||||
str2 := "different"
|
||||
validator := NotEqualTo(&str2)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str1)
|
||||
}
|
||||
}
|
||||
func BenchmarkDomain(b *testing.B) {
|
||||
str := "api.example.com"
|
||||
validator := Domain()
|
||||
@@ -394,16 +181,6 @@ func BenchmarkDomain(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHTTPUrl(b *testing.B) {
|
||||
str := "http://api.example.com/v1/users"
|
||||
validator := HTTPUrl()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHTTPSUrl(b *testing.B) {
|
||||
str := "https://api.example.com/v1/users"
|
||||
validator := HTTPSUrl()
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
@@ -28,8 +27,8 @@ func TestValidator_Validate(t *testing.T) {
|
||||
|
||||
v.Check(&email, "email", Required(), NotEmpty())
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error().(ValidationErrors))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,11 +40,11 @@ func TestValidator_Validate(t *testing.T) {
|
||||
v.Check(email, "email", NotEmpty())
|
||||
v.Check(&password, "password", Required(), MinLen(8))
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
// email: 1 error (Required), password: 1 error (MinLen - Required passes because it's not empty)
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d: %v", len(errors), errors)
|
||||
@@ -58,7 +57,7 @@ func TestValidator_Validate(t *testing.T) {
|
||||
|
||||
v.Check(&value, "password", MinLen(8), MaxLen(5))
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
// Both MinLen and MaxLen will fail (too short and somehow conflicts, but logically MinLen will fail)
|
||||
if len(errors) < 1 {
|
||||
t.Errorf("expected at least 1 error, got %d", len(errors))
|
||||
@@ -66,41 +65,6 @@ func TestValidator_Validate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidator_CheckNested(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
v.CheckNested("user", func(nv *Validator) {
|
||||
email := mail.Nil
|
||||
nv.Check(&email, "email", NotEmpty())
|
||||
|
||||
nv.CheckNested("address", func(av *Validator) {
|
||||
city := ""
|
||||
av.Check(&city, "city", Required())
|
||||
})
|
||||
})
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
|
||||
// Check field paths
|
||||
expectedFields := map[string]bool{
|
||||
"user.email": true,
|
||||
"user.address.city": true,
|
||||
}
|
||||
|
||||
for _, err := range errors {
|
||||
if !expectedFields[err.Field] {
|
||||
t.Errorf("unexpected field path: %s", err.Field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Error(t *testing.T) {
|
||||
t.Run("no errors", func(t *testing.T) {
|
||||
v := New()
|
||||
@@ -192,11 +156,11 @@ func TestOptionalFieldExample(t *testing.T) {
|
||||
v.Check(req.PhoneNumber, "phoneNumber", MinLen(10))
|
||||
v.Check(req.Age, "age", Min(18), Max(120))
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Fatal("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
|
||||
websiteErr := errors.ByField("website")
|
||||
if len(websiteErr) != 1 {
|
||||
@@ -216,140 +180,17 @@ func TestOptionalFieldExample(t *testing.T) {
|
||||
t.Logf("Optional field validation errors: %s", errors.Error())
|
||||
}
|
||||
|
||||
func TestRealWorldExample(t *testing.T) {
|
||||
// Simulate a user registration form
|
||||
type Address struct {
|
||||
City string
|
||||
ZipCode string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Email string
|
||||
Password string
|
||||
Age int
|
||||
Website *string
|
||||
Address Address
|
||||
}
|
||||
|
||||
user := User{
|
||||
Email: "",
|
||||
Password: "123",
|
||||
Age: 15,
|
||||
Website: new("not-a-url"),
|
||||
Address: Address{
|
||||
City: "",
|
||||
ZipCode: "12345",
|
||||
},
|
||||
}
|
||||
|
||||
v := New()
|
||||
|
||||
// Validate user fields
|
||||
v.Check(&user.Email, "email", Required(), NotEmpty())
|
||||
v.Check(&user.Password, "password", Required(), MinLen(8))
|
||||
v.Check(&user.Age, "age", Min(18), Max(120))
|
||||
v.Check(user.Website, "website", URL())
|
||||
|
||||
// Validate nested address
|
||||
v.CheckNested("address", func(av *Validator) {
|
||||
av.Check(&user.Address.City, "city", Required())
|
||||
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, "must be 5 digits"))
|
||||
})
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Fatal("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
expectedErrors := map[string]ErrorCode{
|
||||
"email": ErrorCodeRequired,
|
||||
"password": ErrorCodeTooShort,
|
||||
"age": ErrorCodeOutOfRange,
|
||||
"website": ErrorCodeInvalidURL,
|
||||
"address.city": ErrorCodeRequired,
|
||||
}
|
||||
|
||||
// Check that we have the expected errors
|
||||
for field, expectedCode := range expectedErrors {
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if err.Field == field && err.Code == expectedCode {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error for field '%s' with code '%s'", field, expectedCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Print errors for debugging
|
||||
t.Logf("Validation errors: %s", errors.Error())
|
||||
}
|
||||
|
||||
func TestArrayValidation(t *testing.T) {
|
||||
type Item struct {
|
||||
Name string
|
||||
Price int
|
||||
}
|
||||
|
||||
items := []Item{
|
||||
{Name: "", Price: -10},
|
||||
{Name: "Valid", Price: 100},
|
||||
{Name: "X", Price: 10},
|
||||
}
|
||||
|
||||
v := New()
|
||||
|
||||
// Validate each item
|
||||
for i, item := range items {
|
||||
field := fmt.Sprintf("items[%d]", i)
|
||||
v.CheckNested(field, func(iv *Validator) {
|
||||
iv.Check(&item.Name, "name", Required(), MinLen(2))
|
||||
iv.Check(&item.Price, "price", Min(0))
|
||||
})
|
||||
}
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Fatal("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
|
||||
// Check for specific field paths
|
||||
expectedFields := []string{
|
||||
"items[0].name",
|
||||
"items[0].price",
|
||||
"items[2].name",
|
||||
}
|
||||
|
||||
for _, expectedField := range expectedFields {
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if err.Field == expectedField {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error for field '%s'", expectedField)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Array validation errors: %s", errors.Error())
|
||||
}
|
||||
|
||||
func TestDuplicateValidators(t *testing.T) {
|
||||
t.Run("duplicate MinLen creates two errors", func(t *testing.T) {
|
||||
v := New()
|
||||
name := "abc"
|
||||
v.Check(&name, "name", MinLen(5), MinLen(5))
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors (one per MinLen), got %d", len(errors))
|
||||
}
|
||||
@@ -367,7 +208,7 @@ func TestDuplicateValidators(t *testing.T) {
|
||||
name := ""
|
||||
v.Check(&name, "name", Required(), Required())
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
@@ -378,7 +219,7 @@ func TestDuplicateValidators(t *testing.T) {
|
||||
email := mail.Nil
|
||||
v.Check(&email, "email", NotEmpty(), NotEmpty())
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
@@ -389,7 +230,7 @@ func TestDuplicateValidators(t *testing.T) {
|
||||
name := "test"
|
||||
v.Check(&name, "name", MinLen(5), MinLen(10))
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// MinItems validates that a slice or array has at least the specified minimum number of items.
|
||||
func MinItems(min int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
v := reflect.ValueOf(value)
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
|
||||
}
|
||||
|
||||
if v.Len() < min {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must contain at least %d items", min),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MaxItems validates that a slice or array does not exceed the specified maximum number of items.
|
||||
func MaxItems(max int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
v := reflect.ValueOf(value)
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
|
||||
}
|
||||
|
||||
if v.Len() > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must contain at most %d items", max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UniqueItems validates that all items in a slice or array are unique.
|
||||
func UniqueItems() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
v := reflect.ValueOf(value)
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
|
||||
}
|
||||
|
||||
if v.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
elemType := v.Type().Elem()
|
||||
if !elemType.Comparable() {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "cannot validate uniqueness for non-comparable types")
|
||||
}
|
||||
|
||||
seen := make(map[any]bool)
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
item := v.Index(i).Interface()
|
||||
if seen[item] {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "items must be unique")
|
||||
}
|
||||
seen[item] = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMinItems(t *testing.T) {
|
||||
t.Run("valid slice", func(t *testing.T) {
|
||||
items := []string{"a", "b", "c"}
|
||||
err := MinItems(2)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exact minimum", func(t *testing.T) {
|
||||
items := []int{1, 2}
|
||||
err := MinItems(2)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too few items", func(t *testing.T) {
|
||||
items := []string{"a"}
|
||||
err := MinItems(2)(&items)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
} else if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil slice", func(t *testing.T) {
|
||||
var items *[]string
|
||||
err := MinItems(2)(items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-slice value", func(t *testing.T) {
|
||||
value := "not a slice"
|
||||
err := MinItems(2)(&value)
|
||||
if err == nil || err.Code != ErrorCodeInvalidFormat {
|
||||
t.Error("expected invalid format error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaxItems(t *testing.T) {
|
||||
t.Run("valid slice", func(t *testing.T) {
|
||||
items := []string{"a", "b"}
|
||||
err := MaxItems(5)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exact maximum", func(t *testing.T) {
|
||||
items := []int{1, 2, 3}
|
||||
err := MaxItems(3)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too many items", func(t *testing.T) {
|
||||
items := []string{"a", "b", "c", "d"}
|
||||
err := MaxItems(2)(&items)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
} else if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil slice", func(t *testing.T) {
|
||||
var items *[]string
|
||||
err := MaxItems(2)(items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUniqueItems(t *testing.T) {
|
||||
t.Run("unique items", func(t *testing.T) {
|
||||
items := []string{"a", "b", "c"}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate items", func(t *testing.T) {
|
||||
items := []string{"a", "b", "a"}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
} else if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unique integers", func(t *testing.T) {
|
||||
items := []int{1, 2, 3}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate integers", func(t *testing.T) {
|
||||
items := []int{1, 2, 1}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil slice", func(t *testing.T) {
|
||||
var items *[]string
|
||||
err := UniqueItems()(items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
items := []string{}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty slice, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-comparable type - slice of slices", func(t *testing.T) {
|
||||
items := [][]int{{1, 2}, {3, 4}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for non-comparable type")
|
||||
} else {
|
||||
if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
if err.Message != "cannot validate uniqueness for non-comparable types" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-comparable type - slice of maps", func(t *testing.T) {
|
||||
items := []map[string]int{{"a": 1}, {"b": 2}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for non-comparable type")
|
||||
} else if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-comparable type - struct with slice field", func(t *testing.T) {
|
||||
type NonComparable struct {
|
||||
Items []int
|
||||
}
|
||||
items := []NonComparable{{Items: []int{1, 2}}, {Items: []int{3, 4}}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for non-comparable type")
|
||||
} else if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comparable struct with unique values", func(t *testing.T) {
|
||||
type ComparableStruct struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
items := []ComparableStruct{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for comparable structs, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comparable struct with duplicate values", func(t *testing.T) {
|
||||
type ComparableStruct struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
items := []ComparableStruct{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}, {ID: 1, Name: "a"}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for duplicate comparable structs")
|
||||
} else if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -23,8 +23,8 @@ func TestOptionalByDefault(t *testing.T) {
|
||||
v := New()
|
||||
v.Check(nil, "field", MinLen(5))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors for nil (optional by default), got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors for nil (optional by default), got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestOptionalByDefault(t *testing.T) {
|
||||
var str *string
|
||||
v.Check(str, "field", MinLen(5))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors for nil pointer (optional by default), got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors for nil pointer (optional by default), got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -43,8 +43,8 @@ func TestOptionalByDefault(t *testing.T) {
|
||||
str := "hello world"
|
||||
v.Check(&str, "field", MinLen(5))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestOptionalByDefault(t *testing.T) {
|
||||
str := "hi"
|
||||
v.Check(&str, "field", MinLen(5))
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
@@ -63,8 +63,8 @@ func TestOptionalByDefault(t *testing.T) {
|
||||
str := "hello"
|
||||
v.Check(&str, "field", MinLen(3), MaxLen(10))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestOptionalByDefault(t *testing.T) {
|
||||
str := ""
|
||||
v.Check(&str, "field", MinLen(5))
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation error for empty string")
|
||||
}
|
||||
})
|
||||
@@ -83,7 +83,7 @@ func TestOptionalByDefault(t *testing.T) {
|
||||
var str *string
|
||||
v.Check(str, "field", Required())
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation error for nil with Required()")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EqualTo validates that a value equals another value using deep equality.
|
||||
// Special handling for time.Time to compare instants rather than internal structure.
|
||||
func EqualTo(other any) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
if !areEqual(value, other) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "values must match")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NotEqualTo validates that a value does not equal another value using deep equality.
|
||||
// Special handling for time.Time to compare instants rather than internal structure.
|
||||
func NotEqualTo(other any) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
if areEqual(value, other) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "values must not match")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// areEqual compares two values for equality with special handling for time.Time.
|
||||
func areEqual(a, b any) bool {
|
||||
// Dereference both values
|
||||
aVal, aIsNil := dereferenceValue(a)
|
||||
bVal, bIsNil := dereferenceValue(b)
|
||||
|
||||
// If both are nil, they're equal
|
||||
if aIsNil && bIsNil {
|
||||
return true
|
||||
}
|
||||
|
||||
// If only one is nil, they're not equal
|
||||
if aIsNil || bIsNil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Special handling for time.Time
|
||||
aTime, aIsTime := aVal.(time.Time)
|
||||
bTime, bIsTime := bVal.(time.Time)
|
||||
|
||||
if aIsTime && bIsTime {
|
||||
// Use time.Time.Equal() which compares the instant, ignoring location and monotonic clock
|
||||
return aTime.Equal(bTime)
|
||||
}
|
||||
|
||||
// Fall back to reflect.DeepEqual for all other types
|
||||
return reflect.DeepEqual(aVal, bVal)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEqualTo(t *testing.T) {
|
||||
t.Run("equal strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "password"
|
||||
err := EqualTo(&str2)(&str1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "different"
|
||||
err := EqualTo(&str2)(&str1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("equal integers", func(t *testing.T) {
|
||||
num1 := 42
|
||||
num2 := 42
|
||||
err := EqualTo(&num2)(&num1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different integers", func(t *testing.T) {
|
||||
num1 := 42
|
||||
num2 := 43
|
||||
err := EqualTo(&num2)(&num1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotEqualTo(t *testing.T) {
|
||||
t.Run("different strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "different"
|
||||
err := NotEqualTo(&str2)(&str1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("equal strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "password"
|
||||
err := NotEqualTo(&str2)(&str1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEqualTo_TimeComparison(t *testing.T) {
|
||||
t.Run("same instant same location", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for same instant, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant different location", func(t *testing.T) {
|
||||
// Create the same instant in different time zones
|
||||
utcTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
est, _ := time.LoadLocation("America/New_York")
|
||||
estTime := time.Date(2025, 11, 5, 7, 0, 0, 0, est) // 7am EST = 12pm UTC
|
||||
|
||||
err := EqualTo(utcTime)(estTime)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for same instant in different locations, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different instants same location", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 13, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(time2)(time1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for different instants")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pointer to time same instant", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(&time2)(&time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for pointer to same instant, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil time pointers", func(t *testing.T) {
|
||||
var time1 *time.Time
|
||||
var time2 *time.Time
|
||||
err := EqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil time pointers, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one nil one non-nil time pointer", func(t *testing.T) {
|
||||
var time1 *time.Time
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(&time2)(time1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for nil vs non-nil time")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant with monotonic clock difference", func(t *testing.T) {
|
||||
// Simulate times with different monotonic clock data
|
||||
baseTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time1 := baseTime
|
||||
time.Sleep(1 * time.Millisecond) // Advances monotonic clock
|
||||
time2 := baseTime
|
||||
|
||||
// Even though monotonic clocks differ, the instants are the same
|
||||
err := EqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error despite monotonic clock difference, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotEqualTo_TimeComparison(t *testing.T) {
|
||||
t.Run("different instants", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 13, 0, 0, 0, time.UTC)
|
||||
err := NotEqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for different instants, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant same location", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := NotEqualTo(time2)(time1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for same instant")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant different location", func(t *testing.T) {
|
||||
utcTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
est, _ := time.LoadLocation("America/New_York")
|
||||
estTime := time.Date(2025, 11, 5, 7, 0, 0, 0, est)
|
||||
|
||||
err := NotEqualTo(utcTime)(estTime)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for same instant in different locations")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
// Custom creates a custom validator with a specified error code, message, and validation function.
|
||||
// The validation function should return true if the value is valid, false otherwise.
|
||||
func Custom(code ErrorCode, message string, fn func(value any) bool) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
if !fn(value) {
|
||||
return newValidationError(code, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustom(t *testing.T) {
|
||||
validator := Custom(ErrorCodeCustom, "value must be positive", func(value any) bool {
|
||||
if num, ok := value.(int); ok {
|
||||
return num > 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
wantError bool
|
||||
}{
|
||||
{"valid positive", 5, false},
|
||||
{"invalid zero", 0, true},
|
||||
{"invalid negative", -5, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Custom() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed data/disposable-email-domains/disposable_email_blocklist.conf
|
||||
disposableEmailsRaw []byte
|
||||
testEmails = []string{
|
||||
"acme.com",
|
||||
"acme.net",
|
||||
"acme.org",
|
||||
"ethereal.email",
|
||||
"example.com",
|
||||
"example.net",
|
||||
"example.org",
|
||||
"mailhog.local",
|
||||
"mailslurp.com",
|
||||
"test.com",
|
||||
"test.net",
|
||||
"test.org",
|
||||
"localhost.localdomain",
|
||||
}
|
||||
blacklistedEmails = append(
|
||||
strings.Split(strings.TrimSpace(string(disposableEmailsRaw)), "\n"),
|
||||
testEmails...,
|
||||
)
|
||||
notOneOfBlacklisted = NotOneOfSlice(blacklistedEmails)
|
||||
)
|
||||
|
||||
func NotBlacklisted() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
err := notOneOfBlacklisted(value)
|
||||
|
||||
if err != nil {
|
||||
return newValidationError(
|
||||
ErrorCodeInvalidEmail,
|
||||
"must not be blacklisted",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
|
||||
)
|
||||
|
||||
@@ -61,40 +60,6 @@ func URL() ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPUrl validates that a string is a valid HTTP URL (not HTTPS).
|
||||
func HTTPUrl() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidURL, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(str)
|
||||
if err != nil {
|
||||
return newValidationError(ErrorCodeInvalidURL, "invalid URL format")
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must use http scheme")
|
||||
}
|
||||
|
||||
if parsedURL.Host == "" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must have a host")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPSUrl validates that a string is a valid HTTPS URL (not HTTP).
|
||||
func HTTPSUrl() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
@@ -129,31 +94,6 @@ func HTTPSUrl() ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// UUID validates that a string is a valid UUID.
|
||||
func UUID() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !uuidRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "invalid UUID format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GID validates that a string is a valid GID using gid.ParseGID.
|
||||
// Optionally validates the entity type if provided.
|
||||
//
|
||||
|
||||
@@ -51,75 +51,6 @@ func TestURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPUrl(t *testing.T) {
|
||||
t.Run("valid http URL", func(t *testing.T) {
|
||||
str := "http://example.com"
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid http URL with path", func(t *testing.T) {
|
||||
str := "http://example.com/path/to/resource"
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid http URL with query", func(t *testing.T) {
|
||||
str := "http://example.com?foo=bar"
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - https scheme", func(t *testing.T) {
|
||||
str := "https://example.com"
|
||||
err := HTTPUrl()(&str)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for https")
|
||||
}
|
||||
if err.Message != "URL must use http scheme" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - no scheme", func(t *testing.T) {
|
||||
str := "example.com"
|
||||
err := HTTPUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for missing scheme")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - no host", func(t *testing.T) {
|
||||
str := "http://"
|
||||
err := HTTPUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for missing host")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := HTTPUrl()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPSUrl(t *testing.T) {
|
||||
t.Run("valid https URL", func(t *testing.T) {
|
||||
str := "https://example.com"
|
||||
|
||||
@@ -78,126 +78,3 @@ func Max(max int) ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Range validates that a number is within the specified range (inclusive).
|
||||
func Range(min, max int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num int
|
||||
switch v := actualValue.(type) {
|
||||
case int:
|
||||
num = v
|
||||
case int32:
|
||||
num = int(v)
|
||||
case int64:
|
||||
num = int(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num < min || num > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be between %d and %d", min, max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MinFloat validates that a floating-point number is at least the specified minimum value.
|
||||
func MinFloat(min float64) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num float64
|
||||
switch v := actualValue.(type) {
|
||||
case float32:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case int:
|
||||
num = float64(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num < min {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at least %g", min),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MaxFloat validates that a floating-point number does not exceed the specified maximum value.
|
||||
func MaxFloat(max float64) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num float64
|
||||
switch v := actualValue.(type) {
|
||||
case float32:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case int:
|
||||
num = float64(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at most %g", max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RangeFloat validates that a floating-point number is within the specified range (inclusive).
|
||||
func RangeFloat(min, max float64) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num float64
|
||||
switch v := actualValue.(type) {
|
||||
case float32:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case int:
|
||||
num = float64(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num < min || num > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be between %g and %g", min, max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,28 +66,3 @@ func TestMax(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
min int
|
||||
max int
|
||||
wantError bool
|
||||
}{
|
||||
{"in range", 5, 1, 10, false},
|
||||
{"at min", 1, 1, 10, false},
|
||||
{"at max", 10, 1, 10, false},
|
||||
{"below range", 0, 1, 10, true},
|
||||
{"above range", 11, 1, 10, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Range(tt.min, tt.max)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Range() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,8 +298,8 @@ func TestNoHTML(t *testing.T) {
|
||||
title := "Product Title 2024"
|
||||
v.Check(&title, "title", Required(), NoHTML(), MinLen(3), MaxLen(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -308,8 +308,8 @@ func TestNoHTML(t *testing.T) {
|
||||
title := "José García-O'Brien"
|
||||
v.Check(&title, "title", Required(), NoHTML(), PrintableText(), MinLen(3), MaxLen(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -318,12 +318,12 @@ func TestNoHTML(t *testing.T) {
|
||||
malicious := "<script>alert('xss')</script>"
|
||||
v.Check(&malicious, "content", Required(), NoHTML(), PrintableText())
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
// Should have error from NoHTML
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if strings.Contains(err.Message, "HTML tags") {
|
||||
@@ -341,12 +341,12 @@ func TestNoHTML(t *testing.T) {
|
||||
malicious := "<b>test\x00text</b>"
|
||||
v.Check(&malicious, "content", NoHTML(), PrintableText())
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
// Should have at least one error (NoHTML will catch it first)
|
||||
if len(v.Errors()) < 1 {
|
||||
if ve := v.Error(); ve == nil || len(ve.(ValidationErrors)) < 1 {
|
||||
t.Error("expected at least one validation error")
|
||||
}
|
||||
})
|
||||
@@ -635,8 +635,8 @@ func TestPrintableText(t *testing.T) {
|
||||
title := "Product Title 2024"
|
||||
v.Check(&title, "title", Required(), PrintableText(), MinLen(3), MaxLen(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -840,8 +840,8 @@ func TestSafeText(t *testing.T) {
|
||||
title := "Product Title 2024"
|
||||
v.Check(&title, "title", SafeText(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -850,11 +850,11 @@ func TestSafeText(t *testing.T) {
|
||||
malicious := "<script>alert('xss')</script>"
|
||||
v.Check(&malicious, "content", SafeText(100))
|
||||
|
||||
if !v.HasErrors() {
|
||||
if v.Error() == nil {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
errors := v.Error().(ValidationErrors)
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if strings.Contains(err.Message, "HTML tags") {
|
||||
|
||||
@@ -17,15 +17,9 @@ package validator
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
alphaNumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`)
|
||||
slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
)
|
||||
|
||||
// MinLen validates that a string has at least the specified minimum length.
|
||||
func MinLen(minLength int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
@@ -74,107 +68,6 @@ func MaxLen(maxLength int) ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern validates that a string matches the specified regular expression pattern.
|
||||
func Pattern(pattern string, message string) ValidatorFunc {
|
||||
regex := regexp.MustCompile(pattern)
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if !regex.MatchString(str) {
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("must match pattern: %s", pattern)
|
||||
}
|
||||
return newValidationError(ErrorCodeInvalidFormat, message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AlphaNumeric validates that a string contains only letters and numbers.
|
||||
func AlphaNumeric() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !alphaNumericRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must contain only letters and numbers")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NoSpaces validates that a string does not contain any spaces.
|
||||
func NoSpaces() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(str, " ") {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must not contain spaces")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Slug validates that a string is a valid URL slug (lowercase letters, numbers, and hyphens).
|
||||
func Slug() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !slugRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must be a valid slug (lowercase letters, numbers, and hyphens)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// OneOfSlice validates that a value is one of the allowed values in the slice.
|
||||
// Accepts a slice of any type. Compares by value first, then by string representation.
|
||||
func OneOfSlice[T any](allowed []T) ValidatorFunc {
|
||||
@@ -224,105 +117,3 @@ func OneOfSlice[T any](allowed []T) ValidatorFunc {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NotOneOfSlice validates that a value is not one of the values in the slice.
|
||||
// Accepts a slice of any type. Compares by value first, then by string representation.
|
||||
func NotOneOfSlice[T any](disallowed []T) ValidatorFunc {
|
||||
// Build disallowed map with string keys for flexible comparison
|
||||
disallowedMap := make(map[string]bool)
|
||||
disallowedStrings := make([]string, 0, len(disallowed))
|
||||
|
||||
for _, v := range disallowed {
|
||||
str := fmt.Sprint(v)
|
||||
disallowedMap[str] = true
|
||||
disallowedStrings = append(disallowedStrings, str)
|
||||
}
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
// Handle nil values first
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dereference all pointer levels
|
||||
actualValue := value
|
||||
val := reflect.ValueOf(value)
|
||||
for val.Kind() == reflect.Pointer {
|
||||
if val.IsNil() {
|
||||
return nil
|
||||
}
|
||||
val = val.Elem()
|
||||
actualValue = val.Interface()
|
||||
}
|
||||
|
||||
// First try exact match with DeepEqual
|
||||
for _, disallowedVal := range disallowed {
|
||||
if reflect.DeepEqual(actualValue, disallowedVal) {
|
||||
return newValidationError(
|
||||
ErrorCodeInvalidEnum,
|
||||
fmt.Sprintf("must not be one of: %s", strings.Join(disallowedStrings, ", ")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Then try string comparison (for custom string types)
|
||||
valueStr := fmt.Sprint(actualValue)
|
||||
if disallowedMap[valueStr] {
|
||||
return newValidationError(
|
||||
ErrorCodeInvalidEnum,
|
||||
fmt.Sprintf("must not be one of: %s", strings.Join(disallowedStrings, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// OneOf validates that a value is one of the allowed values.
|
||||
// Accepts strings or types that implement fmt.Stringer as variadic arguments.
|
||||
func OneOf(allowed ...any) ValidatorFunc {
|
||||
allowedMap := make(map[string]bool)
|
||||
allowedStrings := make([]string, 0, len(allowed))
|
||||
|
||||
for _, v := range allowed {
|
||||
var str string
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
str = val
|
||||
case fmt.Stringer:
|
||||
str = val.String()
|
||||
default:
|
||||
str = fmt.Sprint(val)
|
||||
}
|
||||
allowedMap[str] = true
|
||||
allowedStrings = append(allowedStrings, str)
|
||||
}
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var str string
|
||||
switch v := actualValue.(type) {
|
||||
case string:
|
||||
str = v
|
||||
default:
|
||||
if stringer, ok := actualValue.(fmt.Stringer); ok {
|
||||
str = stringer.String()
|
||||
} else {
|
||||
return newValidationError(ErrorCodeInvalidEnum, "value must be a string or implement fmt.Stringer")
|
||||
}
|
||||
}
|
||||
|
||||
if !allowedMap[str] {
|
||||
return newValidationError(
|
||||
ErrorCodeInvalidEnum,
|
||||
fmt.Sprintf("must be one of: %s", strings.Join(allowedStrings, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,215 +67,6 @@ func TestMaxLen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
pattern string
|
||||
message string
|
||||
wantError bool
|
||||
}{
|
||||
{"valid pattern", "abc123", `^[a-z0-9]+$`, "", false},
|
||||
{"invalid pattern", "ABC123", `^[a-z0-9]+$`, "", true},
|
||||
{"custom message", "invalid", `^valid$`, "must be 'valid'", true},
|
||||
{"nil pointer", (*string)(nil), `^test$`, "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Pattern(tt.pattern, tt.message)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Pattern() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
if err != nil && tt.message != "" && err.Message != tt.message {
|
||||
t.Errorf("Expected message '%s', got '%s'", tt.message, err.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlphaNumeric(t *testing.T) {
|
||||
t.Run("valid alphanumeric", func(t *testing.T) {
|
||||
str := "abc123"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only letters", func(t *testing.T) {
|
||||
str := "abcDEF"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only numbers", func(t *testing.T) {
|
||||
str := "123456"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains spaces", func(t *testing.T) {
|
||||
str := "abc 123"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains special characters", func(t *testing.T) {
|
||||
str := "abc-123"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := AlphaNumeric()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNoSpaces(t *testing.T) {
|
||||
t.Run("no spaces", func(t *testing.T) {
|
||||
str := "hello-world"
|
||||
err := NoSpaces()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains spaces", func(t *testing.T) {
|
||||
str := "hello world"
|
||||
err := NoSpaces()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple spaces", func(t *testing.T) {
|
||||
str := "hello world test"
|
||||
err := NoSpaces()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := NoSpaces()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := NoSpaces()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlug(t *testing.T) {
|
||||
t.Run("valid slug", func(t *testing.T) {
|
||||
str := "hello-world"
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid slug with numbers", func(t *testing.T) {
|
||||
str := "hello-world-123"
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single word", func(t *testing.T) {
|
||||
str := "hello"
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains uppercase", func(t *testing.T) {
|
||||
str := "Hello-World"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for uppercase")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains spaces", func(t *testing.T) {
|
||||
str := "hello world"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for spaces")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains underscores", func(t *testing.T) {
|
||||
str := "hello_world"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for underscores")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("starts with hyphen", func(t *testing.T) {
|
||||
str := "-hello"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for leading hyphen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ends with hyphen", func(t *testing.T) {
|
||||
str := "hello-"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for trailing hyphen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := Slug()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOneOf(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -93,96 +93,6 @@ func Before(t any) ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// FutureDate validates that a time is in the future.
|
||||
func FutureDate() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeVal, ok := actualValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
|
||||
}
|
||||
|
||||
if !timeVal.After(time.Now()) {
|
||||
return newValidationError(ErrorCodeOutOfRange, "must be a future date")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PastDate validates that a time is in the past.
|
||||
func PastDate() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeVal, ok := actualValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
|
||||
}
|
||||
|
||||
if !timeVal.Before(time.Now()) {
|
||||
return newValidationError(ErrorCodeOutOfRange, "must be a past date")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MinDuration validates that a duration is at least the specified minimum value.
|
||||
func MinDuration(min time.Duration) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
duration, ok := actualValue.(time.Duration)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
|
||||
}
|
||||
|
||||
if duration < min {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at least %s", min),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MaxDuration validates that a duration does not exceed the specified maximum value.
|
||||
func MaxDuration(max time.Duration) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
duration, ok := actualValue.(time.Duration)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
|
||||
}
|
||||
|
||||
if duration > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at most %s", max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RangeDuration validates that a duration is within the specified range (inclusive).
|
||||
func RangeDuration(min, max time.Duration) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
|
||||
@@ -95,144 +95,6 @@ func TestBefore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFutureDate(t *testing.T) {
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
t.Run("future date", func(t *testing.T) {
|
||||
err := FutureDate()(&future)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("past date", func(t *testing.T) {
|
||||
err := FutureDate()(&past)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var timeVal *time.Time
|
||||
err := FutureDate()(timeVal)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPastDate(t *testing.T) {
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
t.Run("past date", func(t *testing.T) {
|
||||
err := PastDate()(&past)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("future date", func(t *testing.T) {
|
||||
err := PastDate()(&future)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var timeVal *time.Time
|
||||
err := PastDate()(timeVal)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMinDuration(t *testing.T) {
|
||||
minDuration := 10 * time.Minute
|
||||
|
||||
t.Run("duration above minimum", func(t *testing.T) {
|
||||
duration := 20 * time.Minute
|
||||
err := MinDuration(minDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration equal to minimum", func(t *testing.T) {
|
||||
duration := 10 * time.Minute
|
||||
err := MinDuration(minDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration below minimum", func(t *testing.T) {
|
||||
duration := 5 * time.Minute
|
||||
err := MinDuration(minDuration)(&duration)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var duration *time.Duration
|
||||
err := MinDuration(minDuration)(duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaxDuration(t *testing.T) {
|
||||
maxDuration := 1 * time.Hour
|
||||
|
||||
t.Run("duration below maximum", func(t *testing.T) {
|
||||
duration := 30 * time.Minute
|
||||
err := MaxDuration(maxDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration equal to maximum", func(t *testing.T) {
|
||||
duration := 1 * time.Hour
|
||||
err := MaxDuration(maxDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration above maximum", func(t *testing.T) {
|
||||
duration := 2 * time.Hour
|
||||
err := MaxDuration(maxDuration)(&duration)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var duration *time.Duration
|
||||
err := MaxDuration(maxDuration)(duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRangeDuration(t *testing.T) {
|
||||
minDuration := 10 * time.Minute
|
||||
maxDuration := 1 * time.Hour
|
||||
|
||||
@@ -100,29 +100,3 @@ func UserAgent(component string) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// Version returns the version string
|
||||
func Version() string {
|
||||
info := GetBuildInfo()
|
||||
return info.Version
|
||||
}
|
||||
|
||||
// FullVersion returns a detailed version string with all build information
|
||||
func FullVersion() string {
|
||||
info := GetBuildInfo()
|
||||
|
||||
parts := []string{
|
||||
fmt.Sprintf("Version: %s", info.Version),
|
||||
}
|
||||
|
||||
if info.Commit != "unknown" {
|
||||
parts = append(parts, fmt.Sprintf("Commit: %s", info.Commit))
|
||||
}
|
||||
|
||||
if info.BuildDate != "unknown" {
|
||||
parts = append(parts, fmt.Sprintf("Built: %s", info.BuildDate))
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprintf("Go: %s", info.GoVersion))
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
@@ -91,24 +91,6 @@ func TestUserAgent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
v := version.Version()
|
||||
assert.NotEmpty(t, v, "Version should not be empty")
|
||||
}
|
||||
|
||||
func TestFullVersion(t *testing.T) {
|
||||
fv := version.FullVersion()
|
||||
require.NotEmpty(t, fv, "Full version should not be empty")
|
||||
|
||||
// Check that it contains expected fields
|
||||
assert.Contains(t, fv, "Version:", "Full version should contain Version field")
|
||||
assert.Contains(t, fv, "Go:", "Full version should contain Go field")
|
||||
|
||||
// Check it's multiline
|
||||
lines := strings.Split(fv, "\n")
|
||||
assert.GreaterOrEqual(t, len(lines), 2, "Full version should have at least 2 lines")
|
||||
}
|
||||
|
||||
func TestUserAgentConsistency(t *testing.T) {
|
||||
// Test that multiple calls return consistent results
|
||||
ua1 := version.UserAgent("test")
|
||||
|
||||
Reference in New Issue
Block a user