Remove deadcode

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-13 16:50:35 +01:00
parent 7fd4221199
commit ef76a8d2e1
76 changed files with 137 additions and 4424 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
},
)
}

View File

@@ -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
},
)
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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[:]
}

View File

@@ -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{

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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++
}

View File

@@ -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{}

View File

@@ -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,

View File

@@ -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()

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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)
}
})
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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),

View File

@@ -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),

View File

@@ -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,

View File

@@ -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(),

View File

@@ -58,3 +58,4 @@ func UnmarshalBigIntScalar(v any) (int64, error) {
return 0, fmt.Errorf("cannot unmarshal %T into BigInt", v)
}
}

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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())
}
})

View File

@@ -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())
}
}

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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))
}

View File

@@ -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
}
}

View File

@@ -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)
}
})
}

View File

@@ -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()")
}
})

View File

@@ -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)
}

View File

@@ -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")
}
})
}

View File

@@ -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
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}
}

View File

@@ -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.
//

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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") {

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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")