diff --git a/docs/validation/model/README.md b/docs/validation/model/README.md index 159cb380..6843609c 100644 --- a/docs/validation/model/README.md +++ b/docs/validation/model/README.md @@ -21,7 +21,7 @@ OpenFGA model validation ensures that authorization models are syntactically cor |------------|------------|---------|---------------| | `schema-version-required` | Schema | Schema version must be specified | [schema-version-required.md](./schema-version-required.md) | | `schema-version-unsupported` | Schema | Unsupported schema version | [schema-version-unsupported.md](./schema-version-unsupported.md) | -| `invalid-schema-version` | Schema | Invalid schema version format | [invalid-schema-version.md](./invalid-schema-version.md) | +| `invalid-schema-version` | Schema | Declared but not emitted; an unrecognised version reports `invalid-schema` | [invalid-schema-version.md](./invalid-schema-version.md) | | `reserved-type-keywords` | Naming | Type name uses reserved keyword | [reserved-type-keywords.md](./reserved-type-keywords.md) | | `reserved-relation-keywords` | Naming | Relation name uses reserved keyword | [reserved-relation-keywords.md](./reserved-relation-keywords.md) | | `self-error` | Naming | Invalid use of 'self' or 'this' | [self-error.md](./self-error.md) | @@ -44,9 +44,15 @@ OpenFGA model validation ensures that authorization models are syntactically cor | `condition-not-used` | Condition | Defined condition is never used | [condition-not-used.md](./condition-not-used.md) | | `different-nested-condition-name` | Condition | Condition name mismatch in nested structure | [different-nested-condition-name.md](./different-nested-condition-name.md) | | `multiple-modules-in-file` | Multi-file | Multiple modules detected in single file | [multiple-modules-in-file.md](./multiple-modules-in-file.md) | -| `invalid-schema` | Schema | Invalid schema structure | [invalid-schema.md](./invalid-schema.md) | +| `invalid-schema` | Schema | Unrecognised schema version | [invalid-schema.md](./invalid-schema.md) | | `invalid-syntax` | Syntax | Invalid DSL syntax | [invalid-syntax.md](./invalid-syntax.md) | +Five of the codes above are declared but never emitted, so no validation output +carries them: `invalid-schema-version`, `self-error`, `invalid-syntax`, `cyclic-error` +and `cyclic-relation`. An unrecognised schema version reports `invalid-schema`, and a +cycle with no entrypoint reports `relation-no-entry-point`. Their pages are kept +because each is a published URL. + ## Usage Each error documentation includes: diff --git a/docs/validation/model/TROUBLESHOOTING_GUIDE.md b/docs/validation/model/TROUBLESHOOTING_GUIDE.md index 54752850..4494a6c5 100644 --- a/docs/validation/model/TROUBLESHOOTING_GUIDE.md +++ b/docs/validation/model/TROUBLESHOOTING_GUIDE.md @@ -19,9 +19,9 @@ This guide provides quick solutions to common OpenFGA validation errors. For det | Error | Quick Fix | Link | |-------|----------------------------------------------|------| | `invalid-syntax` | Check indentation and keyword spelling | [Details](./invalid-syntax.md) | -| `invalid-schema` | Ensure proper `model` and `schema` structure | [Details](./invalid-schema.md) | +| `invalid-schema` | Declare a recognised version (`1.1` or `1.2`) | [Details](./invalid-schema.md) | | `schema-version-unsupported` | Use supported version (`1.1` or `1.2`) | [Details](./schema-version-unsupported.md) | -| `invalid-schema-version` | Use format `X.Y` (e.g., `1.1`) | [Details](./invalid-schema-version.md) | +| `invalid-schema-version` | Not emitted; an unrecognised version arrives as `invalid-schema` above | [Details](./invalid-schema-version.md) | ### Relationship and Reference Errors diff --git a/docs/validation/model/invalid-schema-version.md b/docs/validation/model/invalid-schema-version.md index b8a763ac..42714952 100644 --- a/docs/validation/model/invalid-schema-version.md +++ b/docs/validation/model/invalid-schema-version.md @@ -6,131 +6,22 @@ ## Summary -The schema version format is invalid or malformed, preventing proper model validation and execution. +An unrecognised schema version is reported under [`invalid-schema`](./invalid-schema.md). ## Description -OpenFGA schema versions must follow a specific format to ensure proper parsing and feature detection. Valid schema versions: -- Follow semantic versioning format (e.g., "1.1", "1.2") -- Use numeric values separated by dots -- Contain only supported version numbers -- Cannot be empty or contain invalid characters +`1.1` and `1.2` are the versions OpenFGA accepts. A version outside that set is reported as `invalid-schema`, with the version as the symbol, so `schema 0.9` gives the message `invalid schema 0.9`. -Invalid schema version formats prevent the validation system from determining which features are available and which validation rules to apply. +Two neighbouring conditions have codes of their own. Version `1.0` is recognised and retired, so it reports [`schema-version-unsupported`](./schema-version-unsupported.md), and a model carrying no version at all reports [`schema-version-required`](./schema-version-required.md). -## Example - -The following models would trigger this error: - -### Invalid version formats: -``` -model - schema v1.1 # Error: contains 'v' prefix - -model - schema 1.1.0.0 # Error: too many version parts - -model - schema 1.x # Error: non-numeric version part - -model - schema "" # Error: empty version string - -model - schema 1.1-beta # Error: contains suffix -``` - -**Error Message:** `Invalid schema version format: 'v1.1'. Schema version must be in format 'X.Y'` +A `schema` line that does not parse as a version at all, such as `schema v1.1`, `schema 1.1.0`, or `schema` on its own, fails during DSL transformation and returns a syntax error with no error code attached. ## Resolution -Use proper schema version format: - -### Correct schema version formats: -``` -model - schema 1.1 # Valid: current recommended version - -model - schema 1.2 # Valid: current recommended version + module support -``` - -### Steps to fix: - -1. **Identify the format issue:** - - Check the error message for the specific format problem - - Review the schema version declaration in your model - -2. **Use correct format:** - - Remove any prefixes (v, version, etc.) - - Use only numeric values separated by a single dot - - Remove any suffixes or additional version parts - -3. **Choose appropriate version:** - - Use `1.1` or `1.2` for new models (recommended) - - Use `1.2` when using modules - -4. **Update and validate:** - - Correct the schema version format - - Ensure your model features are compatible with the chosen version - -## Valid Schema Version Examples - -### ✅ Correct formats: -``` -model - schema 1.1 - -model - schema 1.2 -``` - -### ❌ Invalid formats: -``` -model - schema v1.1 # Prefix not allowed - -model - schema 1.1.0 # Too many parts - -model - schema 1.x # Non-numeric - -model - schema 1.1-beta # Suffix not allowed - -model - schema version 1.1 # Extra text -``` - -## Feature Compatibility Matrix - -| Feature | Schema 1.0 | Schema 1.1 | Schema 1.2 | -|--------------------------------------|------------|------------|------------| -| Supported | ❌ | ✅ | ✅ | -| Basic relations | ✅ | ✅ | ✅ | -| Simple wildcards | ✅ | ✅ | ✅ | -| Wildcards | ✅ | ✅ | ✅ | -| Basic operations | ✅ | ✅ | ✅ | -| Type Restrictions | ❌ | ✅ | ✅ | -| Conditions | ❌ | ✅ | ✅ | -| Operator grouping `(a or (b and c))` | ❌ | ✅ | ✅ | -| Modules | ❌ | ❌ | ✅ | - -> [!WARNING] -> Schema version `1.0` is no longer supported by OpenFGA. Models using this version must be updated to `1.1` or `1.2`. See the [Schema 1.1 Migration Guide](../migrations/schema1.0-to-schema1.1.md) for assistance. +Declare `1.1`, or `1.2` for a model split across files with `module` declarations. See [`invalid-schema`](./invalid-schema.md) for the full example and the compatibility matrix in [`schema-version-unsupported`](./schema-version-unsupported.md) for what each version supports. ## Related Errors -- [`schema-version-required`](./schema-version-required.md) - When no schema version is specified -- [`schema-version-unsupported`](./schema-version-unsupported.md) - When version is not supported -- [`invalid-schema`](./invalid-schema.md) - General schema structure issues - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/schema_validation.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java schema validation package - -The validation uses regular expressions to check version format and ensures consistency across all language implementations. +- [`invalid-schema`](./invalid-schema.md) - An unrecognised schema version +- [`schema-version-required`](./schema-version-required.md) - No schema version is declared +- [`schema-version-unsupported`](./schema-version-unsupported.md) - Version `1.0`, recognised but retired diff --git a/docs/validation/model/invalid-schema.md b/docs/validation/model/invalid-schema.md index 284ae7a4..fd7dc235 100644 --- a/docs/validation/model/invalid-schema.md +++ b/docs/validation/model/invalid-schema.md @@ -6,149 +6,68 @@ ## Summary -The overall schema structure is invalid or malformed, preventing proper model parsing and validation. +The model declares a schema version the validator does not recognise. ## Description -This error occurs when the authorization model's schema structure doesn't conform to OpenFGA's schema requirements. Unlike specific schema version errors, this represents fundamental structural problems with the schema that prevent basic parsing and validation. +`1.1` and `1.2` are the supported versions. A version that parses but is neither of those is reported as `invalid-schema`, with the version itself as the symbol. -Common schema structure issues include: -- Missing required schema components -- Malformed schema declarations -- Invalid schema syntax or formatting -- Structural inconsistencies that violate OpenFGA's schema rules +Two neighbouring conditions have codes of their own. Version `1.0` is recognised and retired, so it reports [`schema-version-unsupported`](./schema-version-unsupported.md), and a model carrying no version at all reports [`schema-version-required`](./schema-version-required.md). -## Example - -The following models would trigger this error: - -### Missing model declaration: -``` -schema 1.1 # Error: Missing 'model' declaration +A malformed `schema` line never reaches validation. `schema v1.1`, `schema 1.1.0`, `schema` with no version, and a file with no `model` declaration all fail during DSL transformation with a syntax error and no error code attached. -type user +## Example -type document - relations - define viewer: [user] -``` +### DSL -### Malformed schema structure: ``` model - # Error: Schema declaration without version - schema + schema 0.9 type user ``` -### Invalid schema syntax: -``` -model { # Error: Invalid syntax for model declaration - schema: 1.1 -} +**Error Message:** `invalid schema 0.9` -type user -``` +The position covers the version itself: line 1, columns 9 to 12. -**Error Message:** `Invalid schema structure: missing required model declaration` +### JSON -## Resolution - -Fix the schema structure to conform to OpenFGA's requirements: - -### Option 1: Add missing model declaration - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] -``` - -### Option 2: Fix schema syntax - -``` -model - schema 1.1 # Proper format: schema followed by version - -type user - -type document - relations - define viewer: [user] +```json +{ + "schema_version": "1.3" +} ``` -### Steps to fix: - -1. **Identify the structural issue:** - - Check the error message for specific schema structure problems - - Review the beginning of your model file for proper format +**Error Message:** `invalid schema 1.3` -2. **Follow OpenFGA schema format:** - - Start with `model` declaration - - Follow with `schema X.Y` version specification - - Use proper indentation and syntax - -3. **Validate basic structure:** - - Ensure model declaration comes first - - Verify schema version is properly specified - - Check that type definitions follow schema declaration - -4. **Test the corrected structure:** - - Validate the model after fixing schema structure - - Ensure the model parses correctly +## Resolution -## Valid Schema Structure +Declare a supported version: -### ✅ Correct schema format: ``` model schema 1.1 type user - relations - define profile_owner: [user] type document relations define viewer: [user] - define editor: [user] or viewer ``` -### ❌ Invalid schema formats: -``` -# Missing model declaration -schema 1.1 -type user - -# Wrong syntax -model { - schema: 1.1 -} -type user - -# Missing schema version -model - schema -type user -``` +Use `1.2` if the model is split across files with `module` declarations, `1.1` otherwise. ## Related Errors -- [`schema-version-required`](./schema-version-required.md) - When schema version is missing -- [`invalid-schema-version`](./invalid-schema-version.md) - When version format is invalid -- [`invalid-syntax`](./invalid-syntax.md) - General syntax issues +- [`schema-version-required`](./schema-version-required.md) - No schema version is declared +- [`schema-version-unsupported`](./schema-version-unsupported.md) - Version `1.0`, recognised but retired +- [`invalid-syntax`](./invalid-syntax.md) - Syntax problems, including a malformed `schema` line ## Implementation Notes -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/schema_validation.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java schema validation package +- Go: `ValidateSchemaVersion` in `pkg/go/validation/schema_validation.go` +- JavaScript: `validate-dsl.ts`, through `createInvalidSchemaVersionError` in `util/exceptions.ts` +- Java: `ModelValidator`, through `ValidationErrorsBuilder.raiseInvalidSchemaVersion` -The validation performs structural checks during the initial parsing phase to ensure the model follows OpenFGA's basic schema requirements. +All three tag the finding `invalid-schema`. The message and the code are pinned in `tests/data/dsl-semantic-validation-cases.yaml` and `tests/data/json-validation-cases.yaml`. diff --git a/docs/validation/model/invalid-syntax.md b/docs/validation/model/invalid-syntax.md index 980c1b0b..7b8fad4f 100644 --- a/docs/validation/model/invalid-syntax.md +++ b/docs/validation/model/invalid-syntax.md @@ -147,7 +147,7 @@ define viewer [user] # Wrong: missing colon ## Related Errors -- [`invalid-schema`](./invalid-schema.md) - When schema structure is invalid +- [`invalid-schema`](./invalid-schema.md) - When the declared version is not recognised - [`invalid-name`](./invalid-name.md) - When names don't follow format rules - [`schema-version-required`](./schema-version-required.md) - When schema declaration is missing diff --git a/docs/validation/model/schema-version-required.md b/docs/validation/model/schema-version-required.md index 3860a628..8745c049 100644 --- a/docs/validation/model/schema-version-required.md +++ b/docs/validation/model/schema-version-required.md @@ -62,7 +62,7 @@ type document ## Related Errors - [`schema-version-unsupported`](./schema-version-unsupported.md) - When an unsupported version is specified -- [`invalid-schema-version`](./invalid-schema-version.md) - When the version format is invalid +- [`invalid-schema-version`](./invalid-schema-version.md) - Declared but not emitted; an unrecognised version reports `invalid-schema` ## Implementation Notes diff --git a/docs/validation/model/schema-version-unsupported.md b/docs/validation/model/schema-version-unsupported.md index 030aeac7..643adbfe 100644 --- a/docs/validation/model/schema-version-unsupported.md +++ b/docs/validation/model/schema-version-unsupported.md @@ -352,8 +352,8 @@ In these cases, OpenFGA will not consider those invalid tuples when evaluating q ## Related Errors - [`schema-version-required`](./schema-version-required.md) - When no schema version is specified -- [`invalid-schema-version`](./invalid-schema-version.md) - When version format is invalid -- [`invalid-schema`](./invalid-schema.md) - General schema structure issues +- [`invalid-schema-version`](./invalid-schema-version.md) - Schema versions and how they are reported +- [`invalid-schema`](./invalid-schema.md) - When the declared version is not recognised ## Implementation Notes diff --git a/pkg/go/validation/complex_operation_validation.go b/pkg/go/validation/complex_operation_validation.go index 94f23282..5b90d814 100644 --- a/pkg/go/validation/complex_operation_validation.go +++ b/pkg/go/validation/complex_operation_validation.go @@ -1,204 +1,168 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ComplexOperationValidator handles validation of complex userset operations. -type ComplexOperationValidator struct { - model *openfgav1.AuthorizationModel - validator *SemanticValidator -} +// validateComplexOperations walks every relation's rewrite tree and reports +// operations that are wrong by construction: a union repeating a member, an +// intersection of conflicting direct assignments, and a difference subtracting +// an operand from itself. +func validateComplexOperations(idx *index, src source) error { + var fs []*Finding + + for _, typeDef := range idx.model.GetTypeDefinitions() { + relations := typeDef.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + fs = append(fs, operationsIn(idx, src, typeDef.GetType(), relationName, + relations[relationName], make(map[string]bool))...) + } + } -func NewComplexOperationValidator(model *openfgav1.AuthorizationModel) *ComplexOperationValidator { - return newComplexOperationValidator(NewSemanticValidator(model)) + return joinFindings(fs...) } -func newComplexOperationValidator(validator *SemanticValidator) *ComplexOperationValidator { - return &ComplexOperationValidator{ - model: validator.model, - validator: validator, +// operationsIn checks one rewrite and recurses into its children. The visited +// map guards the hop a tuple-to-userset makes to its computed relation, so a +// pair of relations referring to each other terminates. +func operationsIn(idx *index, src source, typeName, relationName string, + userset *openfgav1.Userset, visited map[string]bool) []*Finding { + if userset == nil { + return nil } -} -// ValidateComplexOperations validates all complex operations in the model. -func ValidateComplexOperations(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateComplexOperations(collector, NewSemanticValidator(model), lines) -} + var fs []*Finding -func validateComplexOperations(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - opValidator := newComplexOperationValidator(validator) - for _, typeDef := range model.GetTypeDefinitions() { - for relationName, userset := range typeDef.GetRelations() { - opValidator.validateUsersetOperations(collector, typeDef.GetType(), relationName, userset, lines) + if union := userset.GetUnion(); union != nil && len(union.GetChild()) > 0 { + fs = append(fs, redundantUnionMembersIn(idx, src, typeName, relationName, union)...) + + for _, child := range union.GetChild() { + fs = append(fs, operationsIn(idx, src, typeName, relationName, child, visited)...) } } -} -func (cov *ComplexOperationValidator) validateUsersetOperations(collector *ErrorCollector, typeName, relationName string, userset *openfgav1.Userset, lines []string) { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, userset, lines, make(map[string]bool)) -} + if intersection := userset.GetIntersection(); intersection != nil && len(intersection.GetChild()) > 0 { + fs = append(fs, impossibleIntersectionsIn(idx, src, typeName, relationName, intersection)...) -func (cov *ComplexOperationValidator) validateUsersetOperationsWithVisited(collector *ErrorCollector, typeName, relationName string, userset *openfgav1.Userset, lines []string, visited map[string]bool) { - if userset == nil { - return - } - if union := userset.GetUnion(); union != nil { - cov.validateUnionOperationWithVisited(collector, typeName, relationName, union, lines, visited) - } - if intersection := userset.GetIntersection(); intersection != nil { - cov.validateIntersectionOperationWithVisited(collector, typeName, relationName, intersection, lines, visited) + for _, child := range intersection.GetChild() { + fs = append(fs, operationsIn(idx, src, typeName, relationName, child, visited)...) + } } + if diff := userset.GetDifference(); diff != nil { - cov.validateDifferenceOperationWithVisited(collector, typeName, relationName, diff, lines, visited) + fs = append(fs, operationsIn(idx, src, typeName, relationName, diff.GetBase(), visited)...) + fs = append(fs, operationsIn(idx, src, typeName, relationName, diff.GetSubtract(), visited)...) + fs = append(fs, emptyDifferenceIn(idx, src, typeName, relationName, diff)) } - cov.validateNestedOperationsWithVisited(collector, typeName, userset, lines, visited) -} -func (cov *ComplexOperationValidator) validateUnionOperationWithVisited(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string, visited map[string]bool) { - if union == nil || len(union.GetChild()) == 0 { - return - } - cov.checkRedundantUnionMembers(collector, typeName, relationName, union, lines) - for _, child := range union.GetChild() { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, child, lines, visited) + if ttu := userset.GetTupleToUserset(); ttu != nil { + if target := ttu.GetComputedUserset().GetRelation(); target != "" { + key := typeName + "#" + target + if !visited[key] { + visited[key] = true + + if targetUserset := idx.userset(typeName, target); targetUserset != nil { + fs = append(fs, operationsIn(idx, src, typeName, target, targetUserset, visited)...) + } + } + } } - cov.validateUnionSemantics(collector, typeName, relationName, union, lines) -} -func (cov *ComplexOperationValidator) validateIntersectionOperationWithVisited(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string, visited map[string]bool) { - if intersection == nil || len(intersection.GetChild()) == 0 { - return - } - cov.checkImpossibleIntersections(collector, typeName, relationName, intersection, lines) - for _, child := range intersection.GetChild() { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, child, lines, visited) - } - cov.validateIntersectionSemantics(collector, typeName, relationName, intersection, lines) + return fs } -func (cov *ComplexOperationValidator) validateDifferenceOperationWithVisited(collector *ErrorCollector, typeName, relationName string, difference *openfgav1.Difference, lines []string, visited map[string]bool) { - if difference == nil { - return - } - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, difference.GetBase(), lines, visited) - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, difference.GetSubtract(), lines, visited) - cov.validateDifferenceSemantics(collector, typeName, relationName, difference, lines) -} +// redundantUnionMembersIn flags a union member repeated within one union. +func redundantUnionMembersIn(idx *index, src source, typeName, relationName string, + union *openfgav1.Usersets) []*Finding { + var fs []*Finding + + seen := make(map[string]bool) -func (cov *ComplexOperationValidator) checkRedundantUnionMembers(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string) { - seenOperations := make(map[string]bool) for _, child := range union.GetChild() { - operationKey := cov.getUsersetOperationKey(child) - if operationKey == "" { + key := operationKey(child) + if key == "" { continue } - if seenOperations[operationKey] { - lineIndex := GetRelationLineNumber(relationName, lines, nil) - meta := cov.getTypeMeta(typeName) - collector.RaiseRedundantUnionMember(operationKey, relationName, typeName, meta, lineIndex) + + if seen[key] { + line := src.relationLine(relationName, -1) + file, module := typeMeta(idx.typeDef(typeName)) + fs = append(fs, redundantUnionMember(key, relationName, typeName).at(src, line).in(file, module)) } - seenOperations[operationKey] = true + + seen[key] = true } + + return fs } -func (cov *ComplexOperationValidator) checkImpossibleIntersections(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { - typeRestrictions := make([]string, 0) +// impossibleIntersectionsIn flags an intersection whose direct-assignment +// members can never agree. +func impossibleIntersectionsIn(idx *index, src source, typeName, relationName string, + intersection *openfgav1.Usersets) []*Finding { + restrictions := make([]string, 0) + for _, child := range intersection.GetChild() { if child.GetThis() != nil { - typeRestrictions = append(typeRestrictions, "this") + restrictions = append(restrictions, "this") } } - if len(typeRestrictions) <= 1 { - return + + if len(restrictions) <= 1 { + return nil } - uniqueTypes := make(map[string]bool) - for _, t := range typeRestrictions { - uniqueTypes[t] = true + + unique := make(map[string]bool) + for _, restriction := range restrictions { + unique[restriction] = true } - if len(uniqueTypes) > 1 { - lineIndex := GetRelationLineNumber(relationName, lines, nil) - meta := cov.getTypeMeta(typeName) - collector.RaiseImpossibleIntersection(relationName, typeName, typeRestrictions, meta, lineIndex) + + if len(unique) <= 1 { + return nil } -} -func (cov *ComplexOperationValidator) validateUnionSemantics(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string) { - cov.checkSubsumingUnionMembers(collector, typeName, relationName, union, lines) -} + line := src.relationLine(relationName, -1) + file, module := typeMeta(idx.typeDef(typeName)) -func (cov *ComplexOperationValidator) validateIntersectionSemantics(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { - cov.checkRedundantIntersectionMembers(collector, typeName, relationName, intersection, lines) + return []*Finding{impossibleIntersection(relationName, typeName, restrictions).at(src, line).in(file, module)} } -func (cov *ComplexOperationValidator) validateDifferenceSemantics(collector *ErrorCollector, typeName, relationName string, difference *openfgav1.Difference, lines []string) { - baseKey := cov.getUsersetOperationKey(difference.GetBase()) - subtractKey := cov.getUsersetOperationKey(difference.GetSubtract()) - if baseKey != "" && baseKey == subtractKey { - lineIndex := GetRelationLineNumber(relationName, lines, nil) - meta := cov.getTypeMeta(typeName) - collector.RaiseEmptyDifference(relationName, typeName, baseKey, meta, lineIndex) +// emptyDifferenceIn flags a difference subtracting an operand from itself, +// which is empty by construction. +func emptyDifferenceIn(idx *index, src source, typeName, relationName string, + diff *openfgav1.Difference) *Finding { + base := operationKey(diff.GetBase()) + if base == "" || base != operationKey(diff.GetSubtract()) { + return nil } -} -func (cov *ComplexOperationValidator) validateNestedOperationsWithVisited(collector *ErrorCollector, typeName string, userset *openfgav1.Userset, lines []string, visited map[string]bool) { - if ttu := userset.GetTupleToUserset(); ttu != nil { - if targetRelation := ttu.GetComputedUserset().GetRelation(); targetRelation != "" { - key := typeName + "#" + targetRelation - if visited[key] { - return - } - visited[key] = true - if targetUserset := cov.validator.GetRelationUserset(typeName, targetRelation); targetUserset != nil { - cov.validateUsersetOperationsWithVisited(collector, typeName, targetRelation, targetUserset, lines, visited) - } - } - } + line := src.relationLine(relationName, -1) + file, module := typeMeta(idx.typeDef(typeName)) + + return emptyDifference(relationName, typeName, base).at(src, line).in(file, module) } -func (cov *ComplexOperationValidator) getUsersetOperationKey(userset *openfgav1.Userset) string { +// operationKey names a rewrite for comparison: direct assignment, a computed +// relation, or a tuple-to-userset. +func operationKey(userset *openfgav1.Userset) string { if userset == nil { return "" } + if userset.GetThis() != nil { return "this" } - if cu := userset.GetComputedUserset(); cu != nil { - if rel := cu.GetRelation(); rel != "" { - return "computed:" + rel - } - } - if ttu := userset.GetTupleToUserset(); ttu != nil { - tuplesetRel := ttu.GetTupleset().GetRelation() - computedRel := ttu.GetComputedUserset().GetRelation() - return "ttu:" + tuplesetRel + ":" + computedRel - } - return "" -} -func (cov *ComplexOperationValidator) getTypeMeta(typeName string) *Meta { - if typeDef := cov.validator.GetTypeDefinition(typeName); typeDef != nil { - return &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } + if computed := userset.GetComputedUserset(); computed.GetRelation() != "" { + return "computed:" + computed.GetRelation() } - return &Meta{} -} -func (cov *ComplexOperationValidator) checkSubsumingUnionMembers(_ *ErrorCollector, _, _ string, _ *openfgav1.Usersets, _ []string) { - // Would check for cases like [user:*, user] where the wildcard subsumes the - // specific relation. This requires detailed analysis of type restrictions. -} + if ttu := userset.GetTupleToUserset(); ttu != nil { + return "ttu:" + ttu.GetTupleset().GetRelation() + ":" + ttu.GetComputedUserset().GetRelation() + } -func (cov *ComplexOperationValidator) checkRedundantIntersectionMembers(_ *ErrorCollector, _, _ string, _ *openfgav1.Usersets, _ []string) { - // Would check for intersection members that don't restrict the result, e.g. - // intersecting with `this`, which adds no restriction. + return "" } diff --git a/pkg/go/validation/condition_validation.go b/pkg/go/validation/condition_validation.go index 067468aa..960aab81 100644 --- a/pkg/go/validation/condition_validation.go +++ b/pkg/go/validation/condition_validation.go @@ -1,167 +1,108 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ConditionValidator handles condition-related validation. -type ConditionValidator struct { - model *openfgav1.AuthorizationModel - definedConds map[string]*openfgav1.Condition - usedConds map[string]bool - conditionRefs map[string][]ConditionReference -} - -// ConditionReference tracks where a condition is referenced. -type ConditionReference struct { - TypeName string - RelationName string - Context string +// conditionUse is one place a condition is referenced from: a type restriction +// on a relation. +type conditionUse struct { + typeName string + relationName string } -func NewConditionValidator(model *openfgav1.AuthorizationModel) *ConditionValidator { - validator := &ConditionValidator{ - model: model, - definedConds: make(map[string]*openfgav1.Condition), - usedConds: make(map[string]bool), - conditionRefs: make(map[string][]ConditionReference), - } - validator.buildConditionMaps() - return validator -} +// validateConditions runs the three condition checks in the reference's order: +// every referenced condition is defined, every condition's nested name matches +// its key, and every defined condition is referenced. +func validateConditions(model *openfgav1.AuthorizationModel, src source) error { + uses := conditionUses(model) -func (cv *ConditionValidator) buildConditionMaps() { - if cv.model == nil { - return - } - for conditionName, condition := range cv.model.GetConditions() { - cv.definedConds[conditionName] = condition - } - cv.scanForConditionUsage() -} + fs := undefinedConditions(model, src, uses) -func (cv *ConditionValidator) scanForConditionUsage() { - for _, typeDef := range cv.model.GetTypeDefinitions() { - if metaProto := typeDef.GetMetadata(); metaProto != nil { - for relationName, relationMetadata := range metaProto.GetRelations() { - cv.scanRelationMetadataForConditions(typeDef.GetType(), relationName, relationMetadata) - } + // A condition whose nested name property differs from its map key. It + // carries no position, matching the reference. + conditions := model.GetConditions() + for _, conditionKey := range slices.Sorted(maps.Keys(conditions)) { + condition := conditions[conditionKey] + if condition != nil && condition.GetName() != conditionKey { + fs = append(fs, differentNestedConditionName(conditionKey, condition.GetName())) } } -} -func (cv *ConditionValidator) scanRelationMetadataForConditions(typeName, relationName string, rm *openfgav1.RelationMetadata) { - if rm == nil { - return - } - for _, typeRestriction := range rm.GetDirectlyRelatedUserTypes() { - if cond := typeRestriction.GetCondition(); cond != "" { - cv.usedConds[cond] = true - cv.conditionRefs[cond] = append(cv.conditionRefs[cond], ConditionReference{ - TypeName: typeName, - RelationName: relationName, - Context: "type_restriction", - }) + // A condition defined but never referenced. + for _, conditionName := range slices.Sorted(maps.Keys(conditions)) { + if len(uses[conditionName]) > 0 { + continue } - } -} -// ValidateUnusedConditions detects and reports unused condition definitions. -func ValidateUnusedConditions(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateUnusedConditions(collector, NewConditionValidator(model), lines) -} + condition := conditions[conditionName] + file := condition.GetMetadata().GetSourceInfo().GetFile() + module := condition.GetMetadata().GetModule() -func validateUnusedConditions(collector *ErrorCollector, validator *ConditionValidator, lines []string) { - for conditionName, condition := range validator.definedConds { - if !validator.usedConds[conditionName] { - lineIndex := GetConditionLineNumber(conditionName, lines, nil) - meta := &Meta{ - File: condition.GetMetadata().GetSourceInfo().GetFile(), - Module: condition.GetMetadata().GetModule(), - } - collector.RaiseUnusedCondition(conditionName, meta, lineIndex) - } + fs = append(fs, unusedCondition(conditionName).at(src, src.conditionLine(conditionName)).in(file, module)) } -} -// ValidateConditionReferences validates that all referenced conditions are defined. -func ValidateConditionReferences(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateConditionReferences(collector, NewConditionValidator(model), lines) + return joinFindings(fs...) } -func validateConditionReferences(collector *ErrorCollector, validator *ConditionValidator, lines []string) { - model := validator.model - for conditionName := range validator.usedConds { - if _, exists := validator.definedConds[conditionName]; !exists { - for _, ref := range validator.conditionRefs[conditionName] { - // Anchor the relation line lookup to the referencing type's - // declaration so the correct `define` is found when several types - // share a relation name, matching the reference. - typeLineIndex := GetTypeLineNumber(ref.TypeName, lines, nil) - lineIndex := GetRelationLineNumber(ref.RelationName, lines, typeLineIndex) - var file, module string - for _, typeDef := range model.GetTypeDefinitions() { - if typeDef.GetType() == ref.TypeName { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - module = typeDef.GetMetadata().GetModule() - break - } +// conditionUses collects where each condition is referenced, in the order the +// references appear walking types in model order and relations in name order — +// the order the reference implementation reports them in. +func conditionUses(model *openfgav1.AuthorizationModel) map[string][]conditionUse { + uses := make(map[string][]conditionUse) + + for _, typeDef := range model.GetTypeDefinitions() { + relationsMetadata := typeDef.GetMetadata().GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + for _, restriction := range relationsMetadata[relationName].GetDirectlyRelatedUserTypes() { + if condition := restriction.GetCondition(); condition != "" { + uses[condition] = append(uses[condition], conditionUse{ + typeName: typeDef.GetType(), + relationName: relationName, + }) } - meta := &Meta{File: file, Module: module} - collector.RaiseInvalidConditionNameInParameter(conditionName, ref.TypeName, ref.RelationName, conditionName, meta, lineIndex) } } } + + return uses } -// ValidateConditionConsistency checks that each condition's nested name property -// matches its map key, mirroring the reference (validate-dsl.ts): the nested name -// is compared to the key and any difference is reported. -func ValidateConditionConsistency(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - for conditionKey, condition := range model.GetConditions() { - if condition == nil { +// undefinedConditions reports, for every reference to a condition the model +// does not define, one finding per referencing relation. +func undefinedConditions(model *openfgav1.AuthorizationModel, src source, + uses map[string][]conditionUse) []*Finding { + var fs []*Finding + + defined := model.GetConditions() + + for _, conditionName := range slices.Sorted(maps.Keys(uses)) { + if _, ok := defined[conditionName]; ok { continue } - if condition.GetName() != conditionKey { - collector.RaiseDifferentNestedConditionName(conditionKey, condition.GetName()) - } - } -} -func (cv *ConditionValidator) GetDefinedConditions() []string { - conditions := make([]string, 0, len(cv.definedConds)) - for name := range cv.definedConds { - conditions = append(conditions, name) - } - return conditions -} + for _, use := range uses[conditionName] { + // Anchor the relation line lookup to the referencing type's + // declaration so the correct `define` is found when several types + // share a relation name, matching the reference. + line := src.relationLine(use.relationName, src.typeLine(use.typeName)) -func (cv *ConditionValidator) GetUsedConditions() []string { - conditions := make([]string, 0, len(cv.usedConds)) - for name := range cv.usedConds { - conditions = append(conditions, name) - } - return conditions -} + var file, module string + for _, typeDef := range model.GetTypeDefinitions() { + if typeDef.GetType() == use.typeName { + file, module = typeMeta(typeDef) -func (cv *ConditionValidator) IsConditionDefined(conditionName string) bool { - _, exists := cv.definedConds[conditionName] - return exists -} + break + } + } -func (cv *ConditionValidator) IsConditionUsed(conditionName string) bool { - return cv.usedConds[conditionName] -} + fs = append(fs, conditionNotDefined(conditionName, use.typeName, use.relationName). + at(src, line).in(file, module)) + } + } -func (cv *ConditionValidator) GetConditionReferences(conditionName string) []ConditionReference { - return cv.conditionRefs[conditionName] + return fs } diff --git a/pkg/go/validation/condition_validation_test.go b/pkg/go/validation/condition_validation_test.go deleted file mode 100644 index 6c64c0cb..00000000 --- a/pkg/go/validation/condition_validation_test.go +++ /dev/null @@ -1,503 +0,0 @@ -package validation - -import ( - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" -) - -func TestNewConditionValidator(t *testing.T) { - t.Run("Empty model", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{} - validator := NewConditionValidator(model) - - assert.NotNil(t, validator) - assert.Equal(t, model, validator.model) - assert.Empty(t, validator.definedConds) - assert.Empty(t, validator.usedConds) - assert.Empty(t, validator.conditionRefs) - }) - - t.Run("Model with conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "is_owner": {Name: "is_owner"}, - "is_admin": {Name: "is_admin"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "is_owner", - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - - assert.NotNil(t, validator) - assert.Len(t, validator.definedConds, 2) - assert.Len(t, validator.usedConds, 1) - assert.True(t, validator.IsConditionDefined("is_owner")) - assert.True(t, validator.IsConditionDefined("is_admin")) - assert.True(t, validator.IsConditionUsed("is_owner")) - assert.False(t, validator.IsConditionUsed("is_admin")) - }) -} - -func TestConditionValidator_GetDefinedConditions(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "condition1": {Name: "condition1"}, - "condition2": {Name: "condition2"}, - "condition3": {Name: "condition3"}, - }, - } - - validator := NewConditionValidator(model) - defined := validator.GetDefinedConditions() - - assert.Len(t, defined, 3) - assert.Contains(t, defined, "condition1") - assert.Contains(t, defined, "condition2") - assert.Contains(t, defined, "condition3") -} - -func TestConditionValidator_GetUsedConditions(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "used_condition": {Name: "used_condition"}, - "unused_condition": {Name: "unused_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - used := validator.GetUsedConditions() - - assert.Len(t, used, 1) - assert.Contains(t, used, "used_condition") - assert.NotContains(t, used, "unused_condition") -} - -func TestConditionValidator_GetConditionReferences(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "test_condition": {Name: "test_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "test_condition", - }, - }, - }, - "editor": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "test_condition", - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - refs := validator.GetConditionReferences("test_condition") - - assert.Len(t, refs, 2) - - // Check that we have references from both viewer and editor relations - viewerFound := false - editorFound := false - for _, ref := range refs { - if ref.RelationName == "viewer" { - viewerFound = true - assert.Equal(t, "document", ref.TypeName) - assert.Equal(t, "type_restriction", ref.Context) - } - if ref.RelationName == "editor" { - editorFound = true - assert.Equal(t, "document", ref.TypeName) - assert.Equal(t, "type_restriction", ref.Context) - } - } - assert.True(t, viewerFound) - assert.True(t, editorFound) -} - -func TestValidateUnusedConditions(t *testing.T) { - t.Run("No unused conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "used_condition": {Name: "used_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateUnusedConditions(collector, model, nil) - - errors := collector.GetErrors() - assert.Empty(t, errors) - }) - - t.Run("Unused condition detected", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "unused_condition": {Name: "unused_condition"}, - "used_condition": {Name: "used_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateUnusedConditions(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, ConditionNotUsed, errors[0].Metadata.ErrorType) - assert.Equal(t, "unused_condition", errors[0].Metadata.Symbol) - assert.Contains(t, errors[0].Message, "unused_condition") - assert.Contains(t, errors[0].Message, "is not used in the model") - }) - - t.Run("Multiple unused conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "unused1": {Name: "unused1"}, - "unused2": {Name: "unused2"}, - "used_condition": {Name: "used_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateUnusedConditions(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 2) - - // Check that both unused conditions are reported - unusedConditions := make([]string, 0) - for _, err := range errors { - assert.Equal(t, ConditionNotUsed, err.Metadata.ErrorType) - unusedConditions = append(unusedConditions, err.Metadata.Symbol) - } - assert.Contains(t, unusedConditions, "unused1") - assert.Contains(t, unusedConditions, "unused2") - }) -} - -func TestValidateConditionReferences(t *testing.T) { - t.Run("All referenced conditions defined", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "valid_condition": {Name: "valid_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "valid_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Empty(t, errors) - }) - - t.Run("Undefined condition referenced", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "undefined_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, ConditionNotDefined, errors[0].Metadata.ErrorType) - assert.Equal(t, "undefined_condition", errors[0].Metadata.Symbol) - assert.Contains(t, errors[0].Message, "undefined_condition") - }) - - t.Run("Multiple undefined conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "undefined1", - }, - }, - }, - "editor": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "undefined2", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 2) - - // Check that both undefined conditions are reported - undefinedConditions := make([]string, 0) - for _, err := range errors { - assert.Equal(t, ConditionNotDefined, err.Metadata.ErrorType) - undefinedConditions = append(undefinedConditions, err.Metadata.Symbol) - } - assert.Contains(t, undefinedConditions, "undefined1") - assert.Contains(t, undefinedConditions, "undefined2") - }) -} - -func TestValidateConditionConsistency(t *testing.T) { - t.Run("Valid condition consistency", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "valid_condition": {Name: "valid_condition"}, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionConsistency(collector, model, nil) - - errors := collector.GetErrors() - assert.Empty(t, errors) - }) - - t.Run("Nested name differs from map key", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "in_office": {Name: "different_name"}, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionConsistency(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, DifferentNestedConditionName, errors[0].Metadata.ErrorType) - assert.Equal(t, "condition key is `in_office` but nested name property is different_name", errors[0].Message) - }) - - t.Run("Empty name matching empty key is consistent", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "": {Name: ""}, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionConsistency(collector, model, nil) - - assert.Empty(t, collector.GetErrors()) - }) -} - -func TestScanForConditionUsage(t *testing.T) { - t.Run("Complex condition usage scanning", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "condition1": {Name: "condition1"}, - "condition2": {Name: "condition2"}, - "condition3": {Name: "condition3"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "condition1", - }, - { - Type: "group", - Condition: "condition2", - }, - }, - }, - }, - }, - Relations: map[string]*openfgav1.Userset{ - "editor": { - Userset: &openfgav1.Userset_Union{ - Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - - assert.True(t, validator.IsConditionUsed("condition1")) - assert.True(t, validator.IsConditionUsed("condition2")) - assert.False(t, validator.IsConditionUsed("condition3")) - - // Check condition references - refs1 := validator.GetConditionReferences("condition1") - refs2 := validator.GetConditionReferences("condition2") - refs3 := validator.GetConditionReferences("condition3") - - assert.Len(t, refs1, 1) - assert.Len(t, refs2, 1) - assert.Empty(t, refs3) - - assert.Equal(t, "document", refs1[0].TypeName) - assert.Equal(t, "viewer", refs1[0].RelationName) - assert.Equal(t, "type_restriction", refs1[0].Context) - }) -} diff --git a/pkg/go/validation/context.go b/pkg/go/validation/context.go deleted file mode 100644 index 57b9b99f..00000000 --- a/pkg/go/validation/context.go +++ /dev/null @@ -1,131 +0,0 @@ -package validation - -import ( - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// ValidationContext holds the state during model validation -type ValidationContext struct { - TypeMap map[string]*openfgav1.TypeDefinition - VisitedRelations map[string]map[string]bool - UsedConditionNames map[string]bool - FileToModuleMap map[string]map[string]bool - Conditions map[string]*openfgav1.Condition - Lines []string -} - -func NewValidationContext(lines []string) *ValidationContext { - return &ValidationContext{ - TypeMap: make(map[string]*openfgav1.TypeDefinition), - VisitedRelations: make(map[string]map[string]bool), - UsedConditionNames: make(map[string]bool), - FileToModuleMap: make(map[string]map[string]bool), - Conditions: make(map[string]*openfgav1.Condition), - Lines: lines, - } -} - -func (ctx *ValidationContext) AddType(typeName string, typeDef *openfgav1.TypeDefinition) { - ctx.TypeMap[typeName] = typeDef -} - -func (ctx *ValidationContext) GetType(typeName string) (*openfgav1.TypeDefinition, bool) { - typeDef, exists := ctx.TypeMap[typeName] - return typeDef, exists -} - -func (ctx *ValidationContext) MarkRelationVisited(typeName, relationName string) { - if ctx.VisitedRelations[typeName] == nil { - ctx.VisitedRelations[typeName] = make(map[string]bool) - } - ctx.VisitedRelations[typeName][relationName] = true -} - -func (ctx *ValidationContext) IsRelationVisited(typeName, relationName string) bool { - if ctx.VisitedRelations[typeName] == nil { - return false - } - return ctx.VisitedRelations[typeName][relationName] -} - -func (ctx *ValidationContext) MarkConditionUsed(conditionName string) { - ctx.UsedConditionNames[conditionName] = true -} - -func (ctx *ValidationContext) IsConditionUsed(conditionName string) bool { - return ctx.UsedConditionNames[conditionName] -} - -func (ctx *ValidationContext) AddModuleToFile(filename, module string) { - if ctx.FileToModuleMap[filename] == nil { - ctx.FileToModuleMap[filename] = make(map[string]bool) - } - ctx.FileToModuleMap[filename][module] = true -} - -func (ctx *ValidationContext) GetModulesForFile(filename string) []string { - modules := make([]string, 0, len(ctx.FileToModuleMap[filename])) - if moduleMap := ctx.FileToModuleMap[filename]; moduleMap != nil { - for module := range moduleMap { - modules = append(modules, module) - } - } - return modules -} - -func (ctx *ValidationContext) HasMultipleModulesInFile(filename string) bool { - return len(ctx.GetModulesForFile(filename)) > 1 -} - -func (ctx *ValidationContext) DeepCopyVisitedRelations() map[string]map[string]bool { - cp := make(map[string]map[string]bool) - for typeName, relations := range ctx.VisitedRelations { - cp[typeName] = make(map[string]bool) - for relationName, visited := range relations { - cp[typeName][relationName] = visited - } - } - return cp -} - -// RelationTargetParserResult represents the result of parsing a relation target. -type RelationTargetParserResult struct { - Target string `json:"target,omitempty"` - From string `json:"from,omitempty"` - Rewrite RewriteType `json:"rewrite"` -} - -// RewriteType represents the type of rewrite operation. -type RewriteType string - -const ( - RewriteDirect RewriteType = "direct" - RewriteComputedUserset RewriteType = "computed_userset" - RewriteTupleToUserset RewriteType = "tuple_to_userset" -) - -// EntryPointResult represents the result of entry point analysis. -type EntryPointResult struct { - HasEntry bool `json:"hasEntry"` - Loop bool `json:"loop"` -} - -// DestructedAssignableType represents a parsed assignable type. -type DestructedAssignableType struct { - DecodedType string `json:"decodedType"` - DecodedRelation string `json:"decodedRelation,omitempty"` - IsWildcard bool `json:"isWildcard"` - DecodedCondition string `json:"decodedConditionName,omitempty"` -} - -// ValidationRegex represents a validation rule with regex pattern. -type ValidationRegex struct { - Rule string `json:"rule"` - Regex string `json:"regex"` -} - -// ValidationOptions represents options for validation. -type ValidationOptions struct { - TypeValidation string `json:"typeValidation,omitempty"` - RelationValidation string `json:"relationValidation,omitempty"` -} diff --git a/pkg/go/validation/context_test.go b/pkg/go/validation/context_test.go deleted file mode 100644 index 382bacaf..00000000 --- a/pkg/go/validation/context_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package validation - -import ( - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" -) - -func TestNewValidationContext(t *testing.T) { - lines := []string{"line 1", "line 2", "line 3"} - ctx := NewValidationContext(lines) - - assert.NotNil(t, ctx) - assert.NotNil(t, ctx.TypeMap) - assert.NotNil(t, ctx.VisitedRelations) - assert.NotNil(t, ctx.UsedConditionNames) - assert.NotNil(t, ctx.FileToModuleMap) - assert.NotNil(t, ctx.Conditions) - assert.Equal(t, lines, ctx.Lines) - - // Test that maps are initialized - assert.Empty(t, ctx.TypeMap) - assert.Empty(t, ctx.VisitedRelations) - assert.Empty(t, ctx.UsedConditionNames) - assert.Empty(t, ctx.FileToModuleMap) - assert.Empty(t, ctx.Conditions) -} - -func TestValidationContext_AddType(t *testing.T) { - ctx := NewValidationContext(nil) - - typeDef := &openfgav1.TypeDefinition{ - Type: "document", - } - - ctx.AddType("document", typeDef) - - assert.Len(t, ctx.TypeMap, 1) - assert.Equal(t, typeDef, ctx.TypeMap["document"]) -} - -func TestValidationContext_GetType(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test getting non-existent type - typeDef, exists := ctx.GetType("document") - assert.Nil(t, typeDef) - assert.False(t, exists) - - // Add type and test getting it - expectedTypeDef := &openfgav1.TypeDefinition{ - Type: "document", - } - ctx.AddType("document", expectedTypeDef) - - typeDef, exists = ctx.GetType("document") - assert.Equal(t, expectedTypeDef, typeDef) - assert.True(t, exists) -} - -func TestValidationContext_MarkRelationVisited(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no relations are visited - assert.False(t, ctx.IsRelationVisited("document", "viewer")) - - // Mark relation as visited - ctx.MarkRelationVisited("document", "viewer") - - // Check that it's now visited - assert.True(t, ctx.IsRelationVisited("document", "viewer")) - - // Check that other relations are not visited - assert.False(t, ctx.IsRelationVisited("document", "admin")) - assert.False(t, ctx.IsRelationVisited("user", "viewer")) -} - -func TestValidationContext_IsRelationVisited(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test with non-existent type - assert.False(t, ctx.IsRelationVisited("nonexistent", "relation")) - - // Test with existing type but non-existent relation - ctx.MarkRelationVisited("document", "viewer") - assert.False(t, ctx.IsRelationVisited("document", "nonexistent")) - - // Test with existing type and relation - assert.True(t, ctx.IsRelationVisited("document", "viewer")) -} - -func TestValidationContext_MultipleRelationsPerType(t *testing.T) { - ctx := NewValidationContext(nil) - - // Mark multiple relations for the same type - ctx.MarkRelationVisited("document", "viewer") - ctx.MarkRelationVisited("document", "admin") - ctx.MarkRelationVisited("document", "owner") - - // All should be visited - assert.True(t, ctx.IsRelationVisited("document", "viewer")) - assert.True(t, ctx.IsRelationVisited("document", "admin")) - assert.True(t, ctx.IsRelationVisited("document", "owner")) - - // Other types should not be affected - assert.False(t, ctx.IsRelationVisited("user", "viewer")) -} - -func TestValidationContext_MarkConditionUsed(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no conditions are used - assert.False(t, ctx.IsConditionUsed("condition1")) - - // Mark condition as used - ctx.MarkConditionUsed("condition1") - - // Check that it's now used - assert.True(t, ctx.IsConditionUsed("condition1")) - - // Check that other conditions are not used - assert.False(t, ctx.IsConditionUsed("condition2")) -} - -func TestValidationContext_IsConditionUsed(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test with non-existent condition - assert.False(t, ctx.IsConditionUsed("nonexistent")) - - // Mark condition and test - ctx.MarkConditionUsed("test_condition") - assert.True(t, ctx.IsConditionUsed("test_condition")) - assert.False(t, ctx.IsConditionUsed("other_condition")) -} - -func TestValidationContext_AddModuleToFile(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no modules - modules := ctx.GetModulesForFile("test.fga") - assert.Empty(t, modules) - - // Add module to file - ctx.AddModuleToFile("test.fga", "module1") - - // Check that module is added - modules = ctx.GetModulesForFile("test.fga") - assert.Len(t, modules, 1) - assert.Contains(t, modules, "module1") -} - -func TestValidationContext_GetModulesForFile(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test with non-existent file - modules := ctx.GetModulesForFile("nonexistent.fga") - assert.Empty(t, modules) - - // Add multiple modules to same file - ctx.AddModuleToFile("test.fga", "module1") - ctx.AddModuleToFile("test.fga", "module2") - ctx.AddModuleToFile("test.fga", "module3") - - modules = ctx.GetModulesForFile("test.fga") - assert.Len(t, modules, 3) - assert.Contains(t, modules, "module1") - assert.Contains(t, modules, "module2") - assert.Contains(t, modules, "module3") - - // Test that other files are not affected - otherModules := ctx.GetModulesForFile("other.fga") - assert.Empty(t, otherModules) -} - -func TestValidationContext_HasMultipleModulesInFile(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no modules - assert.False(t, ctx.HasMultipleModulesInFile("test.fga")) - - // Add single module - ctx.AddModuleToFile("test.fga", "module1") - assert.False(t, ctx.HasMultipleModulesInFile("test.fga")) - - // Add second module - ctx.AddModuleToFile("test.fga", "module2") - assert.True(t, ctx.HasMultipleModulesInFile("test.fga")) - - // Test with non-existent file - assert.False(t, ctx.HasMultipleModulesInFile("nonexistent.fga")) -} - -func TestValidationContext_DeepCopyVisitedRelations(t *testing.T) { - ctx := NewValidationContext(nil) - - // Add some visited relations - ctx.MarkRelationVisited("document", "viewer") - ctx.MarkRelationVisited("document", "admin") - ctx.MarkRelationVisited("user", "member") - - // Create deep copy - copied := ctx.DeepCopyVisitedRelations() - - // Verify copy has same content - assert.True(t, copied["document"]["viewer"]) - assert.True(t, copied["document"]["admin"]) - assert.True(t, copied["user"]["member"]) - - // Modify original - ctx.MarkRelationVisited("document", "owner") - - // Verify copy is not affected - assert.False(t, copied["document"]["owner"]) - assert.True(t, ctx.IsRelationVisited("document", "owner")) - - // Modify copy - copied["user"]["admin"] = true - - // Verify original is not affected - assert.False(t, ctx.IsRelationVisited("user", "admin")) -} - -func TestRewriteType(t *testing.T) { - // Test that rewrite type constants are defined correctly - assert.Equal(t, "direct", string(RewriteDirect)) - assert.Equal(t, "computed_userset", string(RewriteComputedUserset)) - assert.Equal(t, "tuple_to_userset", string(RewriteTupleToUserset)) -} - -func TestRelationTargetParserResult(t *testing.T) { - result := RelationTargetParserResult{ - Target: "viewer", - From: "parent", - Rewrite: RewriteTupleToUserset, - } - - assert.Equal(t, "viewer", result.Target) - assert.Equal(t, "parent", result.From) - assert.Equal(t, RewriteTupleToUserset, result.Rewrite) -} - -func TestEntryPointResult(t *testing.T) { - result := EntryPointResult{ - HasEntry: true, - Loop: false, - } - - assert.True(t, result.HasEntry) - assert.False(t, result.Loop) -} - -func TestDestructedAssignableType(t *testing.T) { - assignable := DestructedAssignableType{ - DecodedType: "user", - DecodedRelation: "member", - IsWildcard: false, - DecodedCondition: "condition1", - } - - assert.Equal(t, "user", assignable.DecodedType) - assert.Equal(t, "member", assignable.DecodedRelation) - assert.False(t, assignable.IsWildcard) - assert.Equal(t, "condition1", assignable.DecodedCondition) -} - -func TestValidationRegex(t *testing.T) { - regex := ValidationRegex{ - Rule: "[a-zA-Z]+", - Regex: "^[a-zA-Z]+$", - } - - assert.Equal(t, "[a-zA-Z]+", regex.Rule) - assert.Equal(t, "^[a-zA-Z]+$", regex.Regex) -} - -func TestValidationOptions(t *testing.T) { - options := ValidationOptions{ - TypeValidation: "strict", - RelationValidation: "loose", - } - - assert.Equal(t, "strict", options.TypeValidation) - assert.Equal(t, "loose", options.RelationValidation) -} - -func TestValidationContext_Integration(t *testing.T) { - // Test a more complex integration scenario - lines := []string{ - "model", - " schema 1.1", - "type document", - " relations", - " define viewer: [user]", - " define admin: [user]", - "type user", - } - - ctx := NewValidationContext(lines) - - // Add types - docType := &openfgav1.TypeDefinition{Type: "document"} - userType := &openfgav1.TypeDefinition{Type: "user"} - - ctx.AddType("document", docType) - ctx.AddType("user", userType) - - // Mark relations as visited during validation - ctx.MarkRelationVisited("document", "viewer") - ctx.MarkRelationVisited("document", "admin") - - // Mark conditions as used - ctx.MarkConditionUsed("is_owner") - - // Add modules to files - ctx.AddModuleToFile("model.fga", "main") - ctx.AddModuleToFile("permissions.fga", "permissions") - ctx.AddModuleToFile("permissions.fga", "conditions") // Multiple modules in one file - - // Verify state - assert.Len(t, ctx.TypeMap, 2) - assert.True(t, ctx.IsRelationVisited("document", "viewer")) - assert.True(t, ctx.IsRelationVisited("document", "admin")) - assert.False(t, ctx.IsRelationVisited("user", "member")) - assert.True(t, ctx.IsConditionUsed("is_owner")) - assert.False(t, ctx.IsConditionUsed("is_admin")) - assert.False(t, ctx.HasMultipleModulesInFile("model.fga")) - assert.True(t, ctx.HasMultipleModulesInFile("permissions.fga")) - - // Test deep copy doesn't affect original - copied := ctx.DeepCopyVisitedRelations() - // Initialize user map if it doesn't exist - if copied["user"] == nil { - copied["user"] = make(map[string]bool) - } - copied["user"]["member"] = true - assert.False(t, ctx.IsRelationVisited("user", "member")) -} diff --git a/pkg/go/validation/cycle_detection.go b/pkg/go/validation/cycle_detection.go index e1c831ff..dda23160 100644 --- a/pkg/go/validation/cycle_detection.go +++ b/pkg/go/validation/cycle_detection.go @@ -1,6 +1,9 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -10,90 +13,63 @@ type entryPointResult struct { loop bool } -// CycleDetector walks relation rewrites to determine whether each relation has a -// concrete entry point (a directly-assignable type that is not itself a relation -// reference) or is otherwise impossible — either because it bottoms out with no -// entry point, or because it loops back on itself. -// -// This is a port of the reference implementation's hasEntryPointOrLoop -// (pkg/js/validator/validate-dsl.ts): a single traversal per relation that -// yields exactly one outcome, rather than separate cycle and entry-point passes. -type CycleDetector struct { - validator *SemanticValidator -} - -func NewCycleDetector(validator *SemanticValidator) *CycleDetector { - return &CycleDetector{validator: validator} -} +// validateEntryPoints reports relations that have no entry point. Such a +// relation is impossible: either it never reaches a concrete assignable type +// (no entrypoint) or it forms a rewrite loop (potential loop). +func validateEntryPoints(idx *index, src source) error { + var fs []*Finding -// ValidateCyclesAndEntryPoints reports relations that have no entry point. A -// relation with no entry point is impossible: either it never reaches a concrete -// assignable type (no entrypoint) or it forms a rewrite loop (potential loop). -func ValidateCyclesAndEntryPoints(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateCyclesAndEntryPoints(collector, NewSemanticValidator(model), lines) -} - -func validateCyclesAndEntryPoints(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - detector := NewCycleDetector(validator) - - for _, typeDef := range model.GetTypeDefinitions() { + for _, typeDef := range idx.model.GetTypeDefinitions() { relations := typeDef.GetRelations() if len(relations) == 0 { continue } + typeName := typeDef.GetType() - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - for relationName, userset := range relations { - meta := relationMeta(typeDef, relationName) - result := detector.hasEntryPointOrLoop(typeName, relationName, userset, map[string]map[string]bool{}) - if !result.hasEntry { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - if result.loop { - collector.RaiseNoEntryPointLoop(relationName, typeName, meta, lineIndex) - } else { - collector.RaiseNoEntryPoint(relationName, typeName, meta, lineIndex) - } + typeLine := src.typeLine(typeName) + + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + result := hasEntryPointOrLoop(idx, typeName, relationName, relations[relationName], + map[string]map[string]bool{}) + if result.hasEntry { + continue } - } - } -} -// relationMeta resolves the file/module for a relation, falling back to the type. -func relationMeta(typeDef *openfgav1.TypeDefinition, relationName string) *Meta { - if rm, ok := typeDef.GetMetadata().GetRelations()[relationName]; ok { - file := rm.GetSourceInfo().GetFile() - module := rm.GetModule() - if file == "" { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - } - if module == "" { - module = typeDef.GetMetadata().GetModule() + file, module := relationMeta(typeDef, relationName) + line := src.relationLine(relationName, typeLine) + + finding := noEntryPoint(relationName, typeName) + if result.loop { + finding = noEntryPointLoop(relationName, typeName) + } + + fs = append(fs, finding.at(src, line).in(file, module)) } - return &Meta{File: file, Module: module} - } - return &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), } + + return joinFindings(fs...) } -// hasEntryPointOrLoop determines whether a rewrite reaches a concrete entry point. -// visited tracks type#relation pairs already on the current traversal so that a -// rewrite referencing a relation already being resolved is reported as a loop. +// hasEntryPointOrLoop determines whether a rewrite reaches a concrete entry +// point. The visited map tracks type#relation pairs already on the current +// traversal. +// +// It is a port of the reference implementation's hasEntryPointOrLoop +// (pkg/js/validator/validate-dsl.ts): a single traversal per relation that +// yields exactly one outcome, rather than separate cycle and entry-point +// passes. +// +// Only the computed-userset branch turns a revisit into a reported loop. The +// direct type-relation and tuple-to-userset branches skip a reference already +// being resolved and answer loop: false, matching validate-dsl.ts, which reads +// hasEntry off those two recursive calls and discards their loop. // // Sibling branches (the this/ttu type loops, union/intersection children, and a // difference's base/subtract) each get an isolated copy of visited so one // branch's path can't poison another's loop check. The lone linear successor — // the computed-userset tail call — shares visited directly: it accumulates down // the chain to detect back-edges, and avoids an O(n²) copy on deep chains. -func (cd *CycleDetector) hasEntryPointOrLoop(typeName, relationName string, +func hasEntryPointOrLoop(idx *index, typeName, relationName string, rewrite *openfgav1.Userset, visited map[string]map[string]bool) entryPointResult { if relationName == "" || rewrite == nil { return entryPointResult{} @@ -102,136 +78,141 @@ func (cd *CycleDetector) hasEntryPointOrLoop(typeName, relationName string, if visited[typeName] == nil { visited[typeName] = map[string]bool{} } + visited[typeName][relationName] = true - if !cd.validator.RelationDefined(typeName, relationName) { + if !idx.relationDefined(typeName, relationName) { return entryPointResult{} } switch rewrite.GetUserset().(type) { case *openfgav1.Userset_This: // A direct assignment has an entry point if any assignable type is a - // concrete type or wildcard. A type#relation restriction only provides an - // entry point if that referenced relation itself has one. - for _, tr := range cd.directTypeRestrictions(typeName, relationName) { - decodedType := tr.GetType() - decodedRelation := tr.GetRelation() - isWildcard := tr.GetWildcard() != nil - - if decodedRelation == "" || isWildcard { + // concrete type or wildcard. A type#relation restriction only provides + // an entry point if that referenced relation itself has one. + for _, restriction := range idx.directTypeRestrictions(typeName, relationName) { + restrictedType := restriction.GetType() + restrictedRelation := restriction.GetRelation() + + if restrictedRelation == "" || restriction.GetWildcard() != nil { return entryPointResult{hasEntry: true} } - assignable := cd.validator.GetRelationUserset(decodedType, decodedRelation) + + assignable := idx.userset(restrictedType, restrictedRelation) if assignable == nil { - // Matches validate-dsl.ts: returns on the first missing reference - // rather than trying later types. Unreachable in practice (the - // reference pass + cascade gate run first). + // Matches validate-dsl.ts: returns on the first missing + // reference rather than trying later types. Unreachable in + // practice (the reference pass + cascade gate run first). return entryPointResult{} } - if visited[decodedType][decodedRelation] { + + if visited[restrictedType][restrictedRelation] { continue } - if cd.hasEntryPointOrLoop(decodedType, decodedRelation, assignable, copyVisited(visited)).hasEntry { + + if hasEntryPointOrLoop(idx, restrictedType, restrictedRelation, assignable, copyVisited(visited)).hasEntry { return entryPointResult{hasEntry: true} } } + return entryPointResult{} case *openfgav1.Userset_ComputedUserset: computed := rewrite.GetComputedUserset().GetRelation() - if computed == "" || !cd.validator.RelationDefined(typeName, computed) { + if computed == "" || !idx.relationDefined(typeName, computed) { return entryPointResult{} } + if visited[typeName][computed] { return entryPointResult{loop: true} } - // Linear successor: share visited so the chain accumulates (see doc above). - return cd.hasEntryPointOrLoop(typeName, computed, cd.validator.GetRelationUserset(typeName, computed), visited) + + // Linear successor: share visited so the chain accumulates (see above). + return hasEntryPointOrLoop(idx, typeName, computed, idx.userset(typeName, computed), visited) case *openfgav1.Userset_TupleToUserset: ttu := rewrite.GetTupleToUserset() tupleset := ttu.GetTupleset().GetRelation() computed := ttu.GetComputedUserset().GetRelation() - if tupleset == "" || computed == "" { - return entryPointResult{} - } - if !cd.validator.RelationDefined(typeName, tupleset) { + + if tupleset == "" || computed == "" || !idx.relationDefined(typeName, tupleset) { return entryPointResult{} } - for _, tr := range cd.directTypeRestrictions(typeName, tupleset) { - assignableType := tr.GetType() - assignable := cd.validator.GetRelationUserset(assignableType, computed) + + for _, restriction := range idx.directTypeRestrictions(typeName, tupleset) { + assignableType := restriction.GetType() + + assignable := idx.userset(assignableType, computed) if assignable == nil { continue } + if visited[assignableType][computed] { continue } - if cd.hasEntryPointOrLoop(assignableType, computed, assignable, copyVisited(visited)).hasEntry { + + if hasEntryPointOrLoop(idx, assignableType, computed, assignable, copyVisited(visited)).hasEntry { return entryPointResult{hasEntry: true} } } + return entryPointResult{} case *openfgav1.Userset_Union: hasLoop := false + for _, child := range rewrite.GetUnion().GetChild() { - res := cd.hasEntryPointOrLoop(typeName, relationName, child, copyVisited(visited)) - if res.hasEntry { + result := hasEntryPointOrLoop(idx, typeName, relationName, child, copyVisited(visited)) + if result.hasEntry { return entryPointResult{hasEntry: true} } - hasLoop = hasLoop || res.loop + + hasLoop = hasLoop || result.loop } + return entryPointResult{loop: hasLoop} case *openfgav1.Userset_Intersection: for _, child := range rewrite.GetIntersection().GetChild() { - res := cd.hasEntryPointOrLoop(typeName, relationName, child, copyVisited(visited)) - if !res.hasEntry { - return entryPointResult{loop: res.loop} + result := hasEntryPointOrLoop(idx, typeName, relationName, child, copyVisited(visited)) + if !result.hasEntry { + return entryPointResult{loop: result.loop} } } + return entryPointResult{hasEntry: true} case *openfgav1.Userset_Difference: diff := rewrite.GetDifference() - base := cd.hasEntryPointOrLoop(typeName, relationName, diff.GetBase(), copyVisited(visited)) + + base := hasEntryPointOrLoop(idx, typeName, relationName, diff.GetBase(), copyVisited(visited)) if !base.hasEntry { return entryPointResult{loop: base.loop} } - subtract := cd.hasEntryPointOrLoop(typeName, relationName, diff.GetSubtract(), copyVisited(visited)) + + subtract := hasEntryPointOrLoop(idx, typeName, relationName, diff.GetSubtract(), copyVisited(visited)) if !subtract.hasEntry { return entryPointResult{loop: subtract.loop} } + return entryPointResult{hasEntry: true} } return entryPointResult{} } -// directTypeRestrictions returns the directly-related user types declared for a -// relation in its metadata. -func (cd *CycleDetector) directTypeRestrictions(typeName, relationName string) []*openfgav1.RelationReference { - typeDef := cd.validator.GetTypeDefinition(typeName) - if typeDef == nil { - return nil - } - rm, ok := typeDef.GetMetadata().GetRelations()[relationName] - if !ok { - return nil - } - return rm.GetDirectlyRelatedUserTypes() -} - // copyVisited deep-copies the visited map so sibling branches don't share state. func copyVisited(src map[string]map[string]bool) map[string]map[string]bool { dst := make(map[string]map[string]bool, len(src)) + for typeName, relations := range src { inner := make(map[string]bool, len(relations)) - for relationName, v := range relations { - inner[relationName] = v + for relationName, visited := range relations { + inner[relationName] = visited } + dst[typeName] = inner } + return dst } diff --git a/pkg/go/validation/cycle_detection_stress_test.go b/pkg/go/validation/cycle_detection_stress_test.go index c238ed2f..9b00ed0f 100644 --- a/pkg/go/validation/cycle_detection_stress_test.go +++ b/pkg/go/validation/cycle_detection_stress_test.go @@ -43,24 +43,28 @@ func buildWideUnionDSL(width int) string { return b.String() } -// TestCycleDetection_DeepChainTerminatesWithEntry verifies a long linear chain -// of computed usersets terminating in a direct assignment resolves with an entry -// point and terminates. Guards that the shared visited map still flows down the -// chain after dropping the per-call copy. -func TestCycleDetection_DeepChainTerminatesWithEntry(t *testing.T) { - dsl := buildDeepChainDSL(1000) +// entryPointsFor transforms the DSL and runs the entry-point phase alone. +func entryPointsFor(t *testing.T, dsl string) []*Finding { + t.Helper() + model, err := transformer.TransformDSLToProto(dsl) if err != nil { t.Fatalf("failed to transform DSL: %v", err) } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - ValidateCyclesAndEntryPoints(collector, model, lines) + return ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), newSource(dsl))) +} + +// TestCycleDetection_DeepChainTerminatesWithEntry verifies a long linear chain +// of computed usersets terminating in a direct assignment resolves with an entry +// point and terminates: the visited map must flow down the whole chain rather +// than being copied per hop, or deep chains go quadratic. +func TestCycleDetection_DeepChainTerminatesWithEntry(t *testing.T) { + findings := entryPointsFor(t, buildDeepChainDSL(1000)) - if collector.HasErrors() { + if len(findings) > 0 { t.Fatalf("deep computed-userset chain ending in a direct assignment should "+ - "have an entry point, got %d errors: %v", collector.Count(), collector.GetErrors()) + "have an entry point, got %d findings: %v", len(findings), findings) } } @@ -68,19 +72,11 @@ func TestCycleDetection_DeepChainTerminatesWithEntry(t *testing.T) { // computed usersets, all reachable down to a concrete type, resolves with an // entry point and that the sibling-isolating copies don't change the outcome. func TestCycleDetection_WideUnionTerminatesWithEntry(t *testing.T) { - dsl := buildWideUnionDSL(1000) - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - - ValidateCyclesAndEntryPoints(collector, model, lines) + findings := entryPointsFor(t, buildWideUnionDSL(1000)) - if collector.HasErrors() { + if len(findings) > 0 { t.Fatalf("wide union of resolvable members should have an entry point, "+ - "got %d errors: %v", collector.Count(), collector.GetErrors()) + "got %d findings: %v", len(findings), findings) } } @@ -88,24 +84,16 @@ func TestCycleDetection_WideUnionTerminatesWithEntry(t *testing.T) { // computes itself through a chain (a->b->c->a) is still detected as a loop with // no entry point — the shared visited map must accumulate to see the back-edge. func TestCycleDetection_SelfReferentialChainIsLoop(t *testing.T) { - dsl := `model + findings := entryPointsFor(t, `model schema 1.1 type user type doc relations define a: b define b: c - define c: a` - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) + define c: a`) - ValidateCyclesAndEntryPoints(collector, model, lines) - - if !collector.HasErrors() { + if len(findings) == 0 { t.Fatal("self-referential computed chain a->b->c->a should be reported as " + "having no entry point") } @@ -115,7 +103,7 @@ type doc // not poison a sibling that has an entry point: `mixed: loops or direct` resolves // with an entry point. This is what the per-branch copyVisited must preserve. func TestCycleDetection_UnionSiblingIsolation(t *testing.T) { - dsl := `model + findings := entryPointsFor(t, `model schema 1.1 type user type doc @@ -123,29 +111,21 @@ type doc define direct: [user] define loops: selfref define selfref: loops - define mixed: loops or direct` - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - - ValidateCyclesAndEntryPoints(collector, model, lines) + define mixed: loops or direct`) // `loops` and `selfref` legitimately have no entry point and are reported. // `mixed` and `direct` must NOT be reported. - for _, e := range collector.GetErrors() { - if strings.Contains(e.Message, "mixed") || strings.Contains(e.Message, "`direct`") { + for _, finding := range findings { + if strings.Contains(finding.Message, "mixed") || strings.Contains(finding.Message, "`direct`") { t.Fatalf("relation with a resolvable union branch should have an entry "+ - "point, but got error: %s", e.Message) + "point, but got finding: %s", finding.Message) } } } // TestCycleDetection_DeepChainCountStable checks a chain whose base self-loops -// yields exactly one no-entry-point report per relation — the optimization must -// not suppress or duplicate findings. +// yields exactly one no-entry-point report per relation — neither suppressed +// nor duplicated by the visited-map sharing on the linear path. func TestCycleDetection_DeepChainCountStable(t *testing.T) { var b strings.Builder b.WriteString("model\n schema 1.1\ntype user\ntype doc\n relations\n") @@ -157,19 +137,12 @@ func TestCycleDetection_DeepChainCountStable(t *testing.T) { fmt.Fprintf(&b, " define %s: %s\n", name, prev) prev = name } - dsl := b.String() - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - ValidateCyclesAndEntryPoints(collector, model, lines) + findings := entryPointsFor(t, b.String()) // base + r0..r49 = depth+1 relations, all with no entry point. - if collector.Count() != depth+1 { - t.Fatalf("expected %d no-entry-point errors, got %d: %v", - depth+1, collector.Count(), collector.GetErrors()) + if len(findings) != depth+1 { + t.Fatalf("expected %d no-entry-point findings, got %d: %v", + depth+1, len(findings), findings) } } diff --git a/pkg/go/validation/cycle_detection_test.go b/pkg/go/validation/cycle_detection_test.go index 8dbb736f..fbb8820e 100644 --- a/pkg/go/validation/cycle_detection_test.go +++ b/pkg/go/validation/cycle_detection_test.go @@ -7,28 +7,14 @@ import ( "github.com/stretchr/testify/assert" ) -// hasEntry is a small test helper that runs the entry-point traversal for a -// single relation from a fresh visited set. -func (cd *CycleDetector) hasEntry(typeName, relationName string) entryPointResult { - return cd.hasEntryPointOrLoop(typeName, relationName, - cd.validator.GetRelationUserset(typeName, relationName), map[string]map[string]bool{}) +// entryOf runs the entry-point traversal for a single relation from a fresh +// visited set. +func entryOf(idx *index, typeName, relationName string) entryPointResult { + return hasEntryPointOrLoop(idx, typeName, relationName, + idx.userset(typeName, relationName), map[string]map[string]bool{}) } -func TestCycleDetector(t *testing.T) { - t.Run("NewCycleDetector", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - }, - } - - validator := NewSemanticValidator(model) - detector := NewCycleDetector(validator) - - assert.NotNil(t, detector) - assert.Equal(t, validator, detector.validator) - }) - +func TestValidateEntryPoints(t *testing.T) { t.Run("Mutual computed-userset loop has no entry point", func(t *testing.T) { // viewer -> editor -> viewer, neither directly assignable. model := &openfgav1.AuthorizationModel{ @@ -51,20 +37,18 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) + findings := ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{})) - errors := collector.GetErrors() - // Each relation is impossible: one error per relation, all RelationNoEntrypoint. - assert.Len(t, errors, 2) - for _, err := range errors { - assert.Equal(t, RelationNoEntrypoint, err.Metadata.ErrorType) - assert.Contains(t, err.Message, "is an impossible relation") - assert.Contains(t, err.Message, "(potential loop)") + // Each relation is impossible: one finding per relation, all RelationNoEntrypoint. + assert.Len(t, findings, 2) + for _, finding := range findings { + assert.Equal(t, RelationNoEntrypoint, finding.Metadata.Kind) + assert.Contains(t, finding.Message, "is an impossible relation") + assert.Contains(t, finding.Message, "(potential loop)") } }) - t.Run("No errors when relations are reachable", func(t *testing.T) { + t.Run("No findings when relations are reachable", func(t *testing.T) { model := &openfgav1.AuthorizationModel{ TypeDefinitions: []*openfgav1.TypeDefinition{ { @@ -93,9 +77,7 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{}))) }) t.Run("Computed chain terminating in a direct assignment is reachable", func(t *testing.T) { @@ -118,10 +100,8 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) // All three relations resolve to owner's direct assignment. - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{}))) }) } @@ -144,8 +124,7 @@ func TestHasEntryPointOrLoop(t *testing.T) { }, } - detector := NewCycleDetector(NewSemanticValidator(model)) - assert.True(t, detector.hasEntry("document", "viewer").hasEntry) + assert.True(t, entryOf(newIndex(model), "document", "viewer").hasEntry) }) t.Run("Union with this has entry point", func(t *testing.T) { @@ -175,8 +154,7 @@ func TestHasEntryPointOrLoop(t *testing.T) { }, } - detector := NewCycleDetector(NewSemanticValidator(model)) - assert.True(t, detector.hasEntry("document", "viewer").hasEntry) + assert.True(t, entryOf(newIndex(model), "document", "viewer").hasEntry) }) t.Run("Self-referential computed userset is a loop", func(t *testing.T) { @@ -191,10 +169,9 @@ func TestHasEntryPointOrLoop(t *testing.T) { }, } - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.False(t, res.hasEntry) - assert.True(t, res.loop) + result := entryOf(newIndex(model), "document", "viewer") + assert.False(t, result.hasEntry) + assert.True(t, result.loop) }) } @@ -215,7 +192,7 @@ func TestHasEntryPointOrLoop_TupleToUserset(t *testing.T) { if folderViewer != nil { folder.Relations["viewer"] = folderViewer } else { - delete(folder.Metadata.Relations, "viewer") + delete(folder.GetMetadata().GetRelations(), "viewer") } return &openfgav1.AuthorizationModel{ TypeDefinitions: []*openfgav1.TypeDefinition{ @@ -244,39 +221,34 @@ func TestHasEntryPointOrLoop_TupleToUserset(t *testing.T) { t.Run("TTU resolving to a direct assignment has an entry point", func(t *testing.T) { // folder#viewer is directly assignable to user, so document#viewer reaches it. - model := ttuModel(&openfgav1.Userset{Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}) - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.True(t, res.hasEntry) - assert.False(t, res.loop) + idx := newIndex(ttuModel(&openfgav1.Userset{Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}})) + + result := entryOf(idx, "document", "viewer") + assert.True(t, result.hasEntry) + assert.False(t, result.loop) // folder#viewer is itself directly assignable to user, so it also has an entry point. - folderRes := detector.hasEntry("folder", "viewer") - assert.True(t, folderRes.hasEntry) + assert.True(t, entryOf(idx, "folder", "viewer").hasEntry) // document#parent is directly assignable to folder, so it also has an entry point. - parentRes := detector.hasEntry("document", "parent") - assert.True(t, parentRes.hasEntry) + assert.True(t, entryOf(idx, "document", "parent").hasEntry) }) t.Run("TTU whose computed relation is missing on the assignable type has no entry point", func(t *testing.T) { // folder has no `viewer` relation at all, so the computed lookup is nil and // the TTU branch skips it (the assignable == nil continue). - model := ttuModel(nil) - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.False(t, res.hasEntry) - assert.False(t, res.loop) + result := entryOf(newIndex(ttuModel(nil)), "document", "viewer") + assert.False(t, result.hasEntry) + assert.False(t, result.loop) }) t.Run("TTU through a self-looping computed relation has no entry point", func(t *testing.T) { // folder#viewer computes itself, so it never bottoms out at a concrete type. // The TTU branch swallows the looping sub-result and reports no entry point. - model := ttuModel(&openfgav1.Userset{ + idx := newIndex(ttuModel(&openfgav1.Userset{ Userset: &openfgav1.Userset_ComputedUserset{ ComputedUserset: &openfgav1.ObjectRelation{Relation: "viewer"}, }, - }) - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.False(t, res.hasEntry) + })) + + assert.False(t, entryOf(idx, "document", "viewer").hasEntry) }) } diff --git a/pkg/go/validation/duplicate_detection.go b/pkg/go/validation/duplicate_detection.go index b476d87a..45555c41 100644 --- a/pkg/go/validation/duplicate_detection.go +++ b/pkg/go/validation/duplicate_detection.go @@ -1,174 +1,164 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// DuplicateTypeTracker tracks type names to detect duplicates. -type DuplicateTypeTracker struct { - typeNames map[string]bool -} +// validateDuplicates reports everything the model defines twice: a type +// declared twice, a type restriction repeated in a relation, and a partial +// relation definition repeated in a union, intersection or difference. +func validateDuplicates(model *openfgav1.AuthorizationModel, src source) error { + var fs []*Finding -func NewDuplicateTypeTracker() *DuplicateTypeTracker { - return &DuplicateTypeTracker{typeNames: make(map[string]bool)} -} + seenTypes := make(map[string]bool) + + for _, typeDef := range model.GetTypeDefinitions() { + typeName := typeDef.GetType() + if typeName == "" { + continue + } + + file, module := typeMeta(typeDef) + + if seenTypes[typeName] { + fs = append(fs, duplicateTypeName(typeName).at(src, src.typeLine(typeName)).in(file, module)) + } -func (dt *DuplicateTypeTracker) CheckAndAddType(typeName string, collector *ErrorCollector, - meta *Meta, lines []string) bool { - if dt.typeNames[typeName] { - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - collector.RaiseDuplicateTypeName(typeName, meta, typeLineIndex) - return false + seenTypes[typeName] = true + + typeLine := src.typeLine(typeName) + + relationsMetadata := typeDef.GetMetadata().GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + fs = append(fs, duplicateRestrictionsIn(src, relationsMetadata[relationName], + relationName, typeDef, typeLine)...) + fs = append(fs, duplicateOperandsIn(src, typeDef, relationName, typeLine)...) + } } - dt.typeNames[typeName] = true - return true + + return joinFindings(fs...) } -// CheckForDuplicateTypeNamesInRelation checks for duplicate type restrictions within a relation. -func CheckForDuplicateTypeNamesInRelation(collector *ErrorCollector, relationMetadata *openfgav1.RelationMetadata, - relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { +// duplicateRestrictionsIn flags a type restriction repeated in one relation. +// Restrictions are compared as written: `user`, `user:*`, `user#member` and +// `user with cond` are all distinct. +func duplicateRestrictionsIn(src source, relationMetadata *openfgav1.RelationMetadata, + relationName string, typeDef *openfgav1.TypeDefinition, typeLine int) []*Finding { if relationMetadata == nil { - return + return nil } - typeRestrictions := make(map[string]bool) - for _, typeRestriction := range relationMetadata.GetDirectlyRelatedUserTypes() { - if typeRestriction.GetType() == "" { + + var fs []*Finding + + typeName := typeDef.GetType() + file, module := typeMeta(typeDef) + seen := make(map[string]bool) + + for _, restriction := range relationMetadata.GetDirectlyRelatedUserTypes() { + if restriction.GetType() == "" { continue } - typeRestrictionString := typeRestriction.GetType() - if typeRestriction.GetWildcard() != nil { - typeRestrictionString += ":*" - } else if rel := typeRestriction.GetRelation(); rel != "" { - typeRestrictionString += "#" + rel + + written := restriction.GetType() + if restriction.GetWildcard() != nil { + written += ":*" + } else if rel := restriction.GetRelation(); rel != "" { + written += "#" + rel } - if cond := typeRestriction.GetCondition(); cond != "" { - typeRestrictionString += " with " + cond + + if condition := restriction.GetCondition(); condition != "" { + written += " with " + condition } - if typeRestrictions[typeRestrictionString] { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateTypeRestriction(typeRestrictionString, relationName, typeName, meta, lineIndex) - } else { - typeRestrictions[typeRestrictionString] = true + + if seen[written] { + line := src.relationLine(relationName, typeLine) + fs = append(fs, duplicateTypeRestriction(written, relationName, typeName).at(src, line).in(file, module)) } + + seen[written] = true } + + return fs } -// CheckForDuplicatesInRelation checks for duplicate relations in type definitions. -func CheckForDuplicatesInRelation(collector *ErrorCollector, typeDef *openfgav1.TypeDefinition, - relationName string, typeLineIndex *int, lines []string) { - if typeDef == nil { - return +// duplicateOperandsIn flags a partial relation definition repeated in a union +// or intersection, and a difference that subtracts an operand from itself. +func duplicateOperandsIn(src source, typeDef *openfgav1.TypeDefinition, + relationName string, typeLine int) []*Finding { + relation, ok := typeDef.GetRelations()[relationName] + if !ok { + return nil } - relations := typeDef.GetRelations() - relation, exists := relations[relationName] - if !exists { - return + + file, module := relationMeta(typeDef, relationName) + + var fs []*Finding + + raise := func(operand string) { + line := src.relationLine(relationName, typeLine) + fs = append(fs, duplicatePartialRelation(operand, relationName, typeDef.GetType()). + at(src, line).in(file, module)) } - var file, module string - if meta := typeDef.GetMetadata(); meta != nil { - if rm, ok := meta.GetRelations()[relationName]; ok { - file = rm.GetSourceInfo().GetFile() - module = rm.GetModule() - } - if file == "" { - file = meta.GetSourceInfo().GetFile() - } - if module == "" { - module = meta.GetModule() + // Union and intersection both store their members as a *openfgav1.Usersets + // and treat a repeated member as a duplicate, so they share the check. + for _, operands := range []*openfgav1.Usersets{relation.GetUnion(), relation.GetIntersection()} { + if operands == nil { + continue } - } - meta := &Meta{File: file, Module: module} - if union := relation.GetUnion(); union != nil { - checkDuplicatesInOperands(collector, union, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } - if intersection := relation.GetIntersection(); intersection != nil { - checkDuplicatesInOperands(collector, intersection, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } - if diff := relation.GetDifference(); diff != nil { - checkDuplicatesInDifference(collector, diff, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } -} + seen := make(map[string]bool) -// checkDuplicatesInOperands flags duplicate operands within a union or -// intersection. Both operators store their members as a *openfgav1.Usersets and -// treat a repeated member as redundant, so they share this check. -func checkDuplicatesInOperands(collector *ErrorCollector, operands *openfgav1.Usersets, - relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { - if operands == nil { - return - } - relationDefs := make(map[string]bool) - for _, child := range operands.GetChild() { - if relationDef := getRelationDefName(child); relationDef != "" { - if relationDefs[relationDef] { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateType(relationDef, relationName, typeName, meta, lineIndex) - } else { - relationDefs[relationDef] = true + for _, child := range operands.GetChild() { + name := operandName(child) + if name == "" { + continue + } + + if seen[name] { + raise(name) } + + seen[name] = true } } -} -func checkDuplicatesInDifference(collector *ErrorCollector, difference *openfgav1.Difference, - relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { - if difference == nil { - return - } - baseName := getRelationDefName(difference.GetBase()) - subtractName := getRelationDefName(difference.GetSubtract()) - if baseName != "" && baseName == subtractName { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateType(baseName, relationName, typeName, meta, lineIndex) + if diff := relation.GetDifference(); diff != nil { + base := operandName(diff.GetBase()) + if base != "" && base == operandName(diff.GetSubtract()) { + raise(base) + } } + + return fs } -func getRelationDefName(userset *openfgav1.Userset) string { +// operandName renders a union/intersection/difference member the way it was +// written: a computed userset as its relation, a tuple-to-userset as +// `target from tupleset`. +func operandName(userset *openfgav1.Userset) string { if userset == nil { return "" } - if cu := userset.GetComputedUserset(); cu != nil { - if rel := cu.GetRelation(); rel != "" { - return rel - } + + if computed := userset.GetComputedUserset(); computed.GetRelation() != "" { + return computed.GetRelation() } + if ttu := userset.GetTupleToUserset(); ttu != nil { target := ttu.GetComputedUserset().GetRelation() from := ttu.GetTupleset().GetRelation() - if target != "" && from != "" { + + switch { + case target != "" && from != "": return target + " from " + from - } - if target != "" { + case target != "": return target } } - return "" -} -// ValidateDuplicates performs comprehensive duplicate detection on a model. -func ValidateDuplicates(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - typeTracker := NewDuplicateTypeTracker() - for _, typeDef := range model.GetTypeDefinitions() { - typeName := typeDef.GetType() - if typeName == "" { - continue - } - meta := &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - typeTracker.CheckAndAddType(typeName, collector, meta, lines) - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - if metaProto := typeDef.GetMetadata(); metaProto != nil { - for relationName, relationMetadata := range metaProto.GetRelations() { - CheckForDuplicateTypeNamesInRelation(collector, relationMetadata, relationName, typeName, meta, typeLineIndex, lines) - CheckForDuplicatesInRelation(collector, typeDef, relationName, typeLineIndex, lines) - } - } - } + return "" } diff --git a/pkg/go/validation/duplicate_detection_test.go b/pkg/go/validation/duplicate_detection_test.go deleted file mode 100644 index e09dbb43..00000000 --- a/pkg/go/validation/duplicate_detection_test.go +++ /dev/null @@ -1,641 +0,0 @@ -package validation - -import ( - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" -) - -func TestNewDuplicateTypeTracker(t *testing.T) { - tracker := NewDuplicateTypeTracker() - - assert.NotNil(t, tracker) - assert.NotNil(t, tracker.typeNames) - assert.Empty(t, tracker.typeNames) -} - -func TestDuplicateTypeTracker_CheckAndAddType(t *testing.T) { - tests := []struct { - name string - typeNames []string - expectedErrorCount int - expectedDuplicate string - }{ - { - name: "no duplicates", - typeNames: []string{"document", "user", "group"}, - expectedErrorCount: 0, - }, - { - name: "single duplicate", - typeNames: []string{"document", "user", "document"}, - expectedErrorCount: 1, - expectedDuplicate: "document", - }, - { - name: "multiple duplicates", - typeNames: []string{"document", "user", "document", "user", "group"}, - expectedErrorCount: 2, - }, - { - name: "empty type name", - typeNames: []string{"", "document", ""}, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tracker := NewDuplicateTypeTracker() - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - for _, typeName := range tt.typeNames { - tracker.CheckAndAddType(typeName, collector, meta, nil) - } - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 && tt.expectedDuplicate != "" { - found := false - for _, err := range errors { - if err.Metadata.Symbol == tt.expectedDuplicate { - assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) - assert.Contains(t, err.Message, "is a duplicate") - found = true - break - } - } - assert.True(t, found, "Expected duplicate error for %s", tt.expectedDuplicate) - } - }) - } -} - -func TestCheckForDuplicateTypeNamesInRelation(t *testing.T) { - tests := []struct { - name string - relationMetadata *openfgav1.RelationMetadata - relationName string - typeName string - expectedErrorCount int - }{ - { - name: "nil relation metadata", - relationMetadata: nil, - expectedErrorCount: 0, - }, - { - name: "no duplicates", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "group"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 0, - }, - { - name: "duplicate type restrictions", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "duplicate with wildcards", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Wildcard{Wildcard: &openfgav1.Wildcard{}}}, - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Wildcard{Wildcard: &openfgav1.Wildcard{}}}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "duplicate with relations", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "group", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "member"}}, - {Type: "group", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "member"}}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "duplicate with conditions", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user", Condition: "is_owner"}, - {Type: "user", Condition: "is_owner"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "no duplicates with different combinations", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Wildcard{Wildcard: &openfgav1.Wildcard{}}}, - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "member"}}, - {Type: "user", Condition: "is_owner"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - CheckForDuplicateTypeNamesInRelation(collector, tt.relationMetadata, tt.relationName, tt.typeName, meta, nil, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "is a duplicate") - } - }) - } -} - -func TestGetRelationDefName(t *testing.T) { - tests := []struct { - name string - userset *openfgav1.Userset - expected string - }{ - { - name: "computed userset", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - }, - }, - expected: "viewer", - }, - { - name: "tuple to userset with target and from", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_TupleToUserset{ - TupleToUserset: &openfgav1.TupleToUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - Tupleset: &openfgav1.ObjectRelation{ - Relation: "parent", - }, - }, - }, - }, - expected: "viewer from parent", - }, - { - name: "tuple to userset with target only", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_TupleToUserset{ - TupleToUserset: &openfgav1.TupleToUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - Tupleset: &openfgav1.ObjectRelation{}, - }, - }, - }, - expected: "viewer", - }, - { - name: "tuple to userset with from only", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_TupleToUserset{ - TupleToUserset: &openfgav1.TupleToUserset{ - ComputedUserset: &openfgav1.ObjectRelation{}, - Tupleset: &openfgav1.ObjectRelation{ - Relation: "parent", - }, - }, - }, - }, - expected: "", - }, - { - name: "empty userset", - userset: &openfgav1.Userset{}, - expected: "", - }, - { - name: "computed userset with nil relation", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "", - }, - }, - }, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getRelationDefName(tt.userset) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestCheckForDuplicatesInRelation(t *testing.T) { - tests := []struct { - name string - typeDef *openfgav1.TypeDefinition - relationName string - expectedErrorCount int - }{ - { - name: "nil type definition", - typeDef: nil, - relationName: "viewer", - expectedErrorCount: 0, - }, - { - name: "nil relations", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: nil, - }, - relationName: "viewer", - expectedErrorCount: 0, - }, - { - name: "simple relation with no duplicates", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - relationName: "viewer", - expectedErrorCount: 0, - }, - { - name: "union with duplicates", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_Union{ - Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - }, - }, - }, - }, - }, - }, - relationName: "viewer", - expectedErrorCount: 1, - }, - { - name: "intersection with duplicates", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "can_edit": { - Userset: &openfgav1.Userset_Intersection{ - Intersection: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - }, - }, - }, - }, - }, - }, - relationName: "can_edit", - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - - CheckForDuplicatesInRelation(collector, tt.typeDef, tt.relationName, nil, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - } - }) - } -} - -func TestValidateDuplicates(t *testing.T) { - tests := []struct { - name string - model *openfgav1.AuthorizationModel - expectedErrorCount int - expectedErrorTypes []ValidationErrorType - }{ - { - name: "nil model", - model: nil, - expectedErrorCount: 0, - }, - { - name: "nil type definitions", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: nil, - }, - expectedErrorCount: 0, - }, - { - name: "no duplicates", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - { - Type: "user", - }, - }, - }, - expectedErrorCount: 0, - }, - { - name: "duplicate type names", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - }, - { - Type: "document", - }, - }, - }, - expectedErrorCount: 1, - expectedErrorTypes: []ValidationErrorType{DuplicatedError}, - }, - { - name: "duplicate type restrictions in relation", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, - }, - }, - }, - }, - }, - }, - }, - expectedErrorCount: 1, - expectedErrorTypes: []ValidationErrorType{DuplicatedError}, - }, - { - name: "multiple types of duplicates", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, - }, - }, - }, - }, - }, - { - Type: "document", // Duplicate type name - }, - }, - }, - expectedErrorCount: 2, - expectedErrorTypes: []ValidationErrorType{DuplicatedError, DuplicatedError}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - - ValidateDuplicates(collector, tt.model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - for i, expectedType := range tt.expectedErrorTypes { - if i < len(errors) { - assert.Equal(t, expectedType, errors[i].Metadata.ErrorType) - } - } - }) - } -} - -func TestCheckDuplicatesInUnion(t *testing.T) { - tests := []struct { - name string - union *openfgav1.Usersets - expectedErrorCount int - }{ - { - name: "nil union", - union: nil, - expectedErrorCount: 0, - }, - { - name: "union with nil child", - union: &openfgav1.Usersets{ - Child: nil, - }, - expectedErrorCount: 0, - }, - { - name: "union with no duplicates", - union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - }, - }, - }, - }, - expectedErrorCount: 0, - }, - { - name: "union with duplicates", - union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - }, - }, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - checkDuplicatesInOperands(collector, tt.union, "test_relation", "test_type", meta, nil, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - } - }) - } -} - -func TestValidateDuplicates_Integration(t *testing.T) { - t.Run("Duplicate Type Detection", func(t *testing.T) { - collector := NewErrorCollector(nil) - - // Model with duplicate type names - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - {Type: "document"}, // Duplicate - }, - } - - ValidateDuplicates(collector, model, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "is a duplicate") - }) - - t.Run("Duplicate Type Restriction Detection", func(t *testing.T) { - collector := NewErrorCollector(nil) - - // Model with duplicate type restrictions in relation - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, // Duplicate - }, - }, - }, - }, - }, - }, - } - - ValidateDuplicates(collector, model, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - }) -} diff --git a/pkg/go/validation/error_collector.go b/pkg/go/validation/error_collector.go deleted file mode 100644 index 386a091e..00000000 --- a/pkg/go/validation/error_collector.go +++ /dev/null @@ -1,350 +0,0 @@ -package validation - -import ( - "fmt" - "strings" -) - -// wordIndex returns the index of symbol in rawLine matched on word boundaries, -// mirroring the reference's `\bsymbol\b` lookup. This avoids matching a symbol -// as a substring of another word (e.g. finding `t` inside `type`). Returns 0 -// when the symbol is not found, matching the reference's fallback. -// -// The boundary check is done directly rather than via a per-call compiled -// regexp: `\b` only requires that the characters flanking the match are not word -// characters, which is cheap to test in place and avoids recompiling a pattern -// for every error. -func wordIndex(rawLine, symbol string) int { - if symbol == "" { - return 0 - } - // Only attempt a word-boundary match when the symbol begins and ends with a - // word character; symbols containing non-word characters (e.g. `user:*`) - // can't match `\bsymbol\b` and fall through to the substring search. - if isWordChar(symbol[0]) && isWordChar(symbol[len(symbol)-1]) { - for off := 0; ; { - idx := strings.Index(rawLine[off:], symbol) - if idx < 0 { - break - } - pos := off + idx - beforeOK := pos == 0 || !isWordChar(rawLine[pos-1]) - afterPos := pos + len(symbol) - afterOK := afterPos == len(rawLine) || !isWordChar(rawLine[afterPos]) - if beforeOK && afterOK { - return pos - } - off = pos + 1 - } - } - if idx := strings.Index(rawLine, symbol); idx >= 0 { - return idx - } - return 0 -} - -// isWordChar reports whether b is a regexp `\w` character ([0-9A-Za-z_]). -func isWordChar(b byte) bool { - return b == '_' || - (b >= '0' && b <= '9') || - (b >= 'a' && b <= 'z') || - (b >= 'A' && b <= 'Z') -} - -// ErrorCollector collects validation errors during model validation -// This is equivalent to the JS ExceptionCollector class -type ErrorCollector struct { - errors []*ValidationError - lines []string // DSL lines for line number resolution -} - -// NewErrorCollector creates a new error collector. -func NewErrorCollector(lines []string) *ErrorCollector { - return &ErrorCollector{ - errors: make([]*ValidationError, 0), - lines: lines, - } -} - -// GetErrors returns all collected errors. -func (c *ErrorCollector) GetErrors() []*ValidationError { - return c.errors -} - -// HasErrors returns true if any errors have been collected. -func (c *ErrorCollector) HasErrors() bool { - return len(c.errors) > 0 -} - -// Count returns the number of errors collected. -func (c *ErrorCollector) Count() int { - return len(c.errors) -} - -// addError is a helper to add an error to the collection. -func (c *ErrorCollector) addError(message string, errorType ValidationErrorType, symbol string, - lineIndex *int, meta *Meta, customResolver ErrorCustomResolver) { - var line *LineRange - var column *ColumnRange - - // Calculate line and column positions if lineIndex is provided - if lineIndex != nil && *lineIndex >= 0 && *lineIndex < len(c.lines) { - line = &LineRange{Start: *lineIndex, End: *lineIndex} - - // Find symbol position in line for column calculation, matching on word - // boundaries as the reference does. - rawLine := c.lines[*lineIndex] - symbolPos := wordIndex(rawLine, symbol) - - if customResolver != nil { - symbolPos = customResolver(symbolPos, rawLine, symbol) - } - - if symbolPos >= 0 { - column = &ColumnRange{ - Start: symbolPos, - End: symbolPos + len(symbol), - } - } - } - - metadata := &ErrorMetadata{ - Symbol: symbol, - ErrorType: errorType, - } - - if meta != nil { - metadata.Module = meta.Module - // Set file in both metadata and error for consistency with JS implementation - } - - validationErr := &ValidationError{ - Message: message, - Line: line, - Column: column, - Metadata: metadata, - } - - if meta != nil { - validationErr.File = meta.File - } - - c.errors = append(c.errors, validationErr) -} - -// RaiseInvalidName raises an invalid name error. -func (c *ErrorCollector) RaiseInvalidName(symbol, clause string, typeName *string, lineIndex *int, meta *Meta) { - var message string - if typeName != nil { - message = fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", symbol, *typeName, clause) - } else { - message = fmt.Sprintf("type '%s' does not match naming rule: '%s'.", symbol, clause) - } - c.addError(message, InvalidName, symbol, lineIndex, meta, nil) -} - -// RaiseReservedTypeName raises a reserved type name error. -func (c *ErrorCollector) RaiseReservedTypeName(symbol string, lineIndex *int, meta *Meta) { - message := "a type cannot be named 'self' or 'this'." - c.addError(message, ReservedTypeKeywords, symbol, lineIndex, meta, nil) -} - -// RaiseReservedRelationName raises a reserved relation name error. -func (c *ErrorCollector) RaiseReservedRelationName(symbol string, lineIndex *int, meta *Meta) { - message := "a relation cannot be named 'self' or 'this'." - c.addError(message, ReservedRelationKeywords, symbol, lineIndex, meta, nil) -} - -// RaiseTupleUsersetRequiresDirect raises an error for tuple-to-userset not being direct. -func (c *ErrorCollector) RaiseTupleUsersetRequiresDirect(symbol, typeName, relation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` relation used inside from allows only direct relation.", symbol) - - // Custom resolver for "from" clause positioning - customResolver := func(wordIdx int, rawLine, value string) int { - clauseStartsAt := strings.Index(rawLine, "from") + len("from") - if clauseStartsAt >= len("from") { - wordIdx = clauseStartsAt + strings.Index(rawLine[clauseStartsAt:], value) - } - return wordIdx - } - - c.addError(message, TuplesetNotDirect, symbol, lineIndex, meta, customResolver) -} - -// RaiseDuplicateTypeName raises a duplicate type name error. -func (c *ErrorCollector) RaiseDuplicateTypeName(symbol string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the type `%s` is a duplicate.", symbol) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseDuplicateTypeRestriction raises a duplicate type restriction error. -func (c *ErrorCollector) RaiseDuplicateTypeRestriction(symbol, relationName, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the type restriction `%s` is a duplicate in the relation `%s`.", symbol, relationName) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseUndefinedType raises an error for undefined type references. -func (c *ErrorCollector) RaiseUndefinedType(typeName, relationName, parentTypeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Type '%s' is not defined (referenced in relation '%s' of type '%s')", typeName, relationName, parentTypeName) - c.addError(message, UndefinedType, typeName, lineIndex, meta, nil) -} - -// RaiseUndefinedRelation raises an error for undefined relation references. -func (c *ErrorCollector) RaiseUndefinedRelation(relationName, typeName, parentRelation, parentTypeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Relation '%s' is not defined on type '%s' (referenced in relation '%s' of type '%s')", relationName, typeName, parentRelation, parentTypeName) - c.addError(message, UndefinedRelation, relationName, lineIndex, meta, nil) -} - -// RaiseDuplicateType raises a duplicate type error in relation. -func (c *ErrorCollector) RaiseDuplicateType(symbol, relationName, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the partial relation definition `%s` is a duplicate in the relation `%s`.", - symbol, relationName) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseDuplicateRelationshipDefinition raises a duplicate relationship definition error. -func (c *ErrorCollector) RaiseDuplicateRelationshipDefinition(symbol string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the relation '%s' is defined more than once.", symbol) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseNoEntryPointLoop raises an error for impossible relation with potential loop. -func (c *ErrorCollector) RaiseNoEntryPointLoop(symbol, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is an impossible relation for `%s` (potential loop).", symbol, typeName) - c.addError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil) -} - -// RaiseNoEntryPoint raises an error for impossible relation without entry point. -func (c *ErrorCollector) RaiseNoEntryPoint(symbol, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is an impossible relation for `%s` (no entrypoint).", symbol, typeName) - c.addError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidRelationOnTupleset raises an error for invalid relation on tupleset. -func (c *ErrorCollector) RaiseInvalidRelationOnTupleset(symbol, typeName, typeDef, relationName, - offendingRelation, parent string, lineIndex *int, meta *Meta) { - message := fmt.Sprintf("the `%s` relation definition on type `%s` is not valid: `%s` does not exist on `%s`, which is of type `%s`.", - offendingRelation, typeDef, offendingRelation, parent, typeName) - c.addError(message, InvalidRelationOnTupleset, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidTypeRelation raises an error for invalid type relation. -func (c *ErrorCollector) RaiseInvalidTypeRelation(symbol, typeName, relationName, offendingRelation, - offendingType string, lineIndex *int, meta *Meta) { - message := fmt.Sprintf("`%s` is not a valid relation for `%s`.", offendingRelation, typeName) - c.addError(message, InvalidRelationType, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidType raises an error for invalid type. -func (c *ErrorCollector) RaiseInvalidType(symbol, typeName, relation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is not a valid type.", symbol) - // The invalid type appears in the assignable-types list (after the colon), - // which may share a name with the relation key before the colon. Resolve the - // column to the value side so it marks the type, not the relation name — - // mirroring the reference's customResolver. - resolver := func(_ int, rawLine, sym string) int { - colon := strings.Index(rawLine, ":") - if colon < 0 { - return wordIndex(rawLine, sym) - } - value := rawLine[colon+1:] - idx := wordIndex(value, sym) - return colon + 1 + idx - } - c.addError(message, InvalidType, symbol, lineIndex, meta, resolver) -} - -// RaiseAssignableRelationMustHaveTypes raises an error for assignable relations without types. -func (c *ErrorCollector) RaiseAssignableRelationMustHaveTypes(symbol string, lineIndex *int) { - message := fmt.Sprintf("the assignable relation '%s' must have at least one assignable type.", symbol) - c.addError(message, AssignableRelationsMustHaveType, symbol, lineIndex, nil, nil) -} - -// RaiseAssignableTypeWildcardRelation raises an error for wildcard with relation. -func (c *ErrorCollector) RaiseAssignableTypeWildcardRelation(symbol, typeName, relation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the type restriction '%s' on relation '%s' of type '%s' is not allowed to have both a wildcard and a relation.", - symbol, relation, typeName) - c.addError(message, TypeRestrictionCannotHaveWildcardAndRelation, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidRelationError raises an error for invalid relation reference. -func (c *ErrorCollector) RaiseInvalidRelationError(symbol, typeName, relation string, validRelations []string, - lineIndex *int, meta *Meta) { - message := fmt.Sprintf("the relation `%s` does not exist.", symbol) - c.addError(message, MissingDefinition, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidSchemaVersion raises an error for a schema version that was never -// valid (e.g. "0.9", "2.0"). This is distinct from a version that is recognized -// but no longer supported (see RaiseSchemaVersionUnsupported). -func (c *ErrorCollector) RaiseInvalidSchemaVersion(symbol string, lineIndex *int) { - message := fmt.Sprintf("invalid schema %s", symbol) - c.addError(message, InvalidSchema, symbol, lineIndex, nil, nil) -} - -// RaiseSchemaVersionUnsupported raises an error for a recognized but retired -// schema version (e.g. "1.0"). -func (c *ErrorCollector) RaiseSchemaVersionUnsupported(symbol string, lineIndex *int) { - message := "schema version no longer supported" - c.addError(message, SchemaVersionUnsupported, symbol, lineIndex, nil, nil) -} - -// RaiseSchemaVersionRequired raises an error for missing schema version. -func (c *ErrorCollector) RaiseSchemaVersionRequired(symbol string, lineIndex *int) { - message := "schema version required" - c.addError(message, SchemaVersionRequired, symbol, lineIndex, nil, nil) -} - -// RaiseMaximumOneDirectRelationship raises an error for multiple direct relationships. -func (c *ErrorCollector) RaiseMaximumOneDirectRelationship(symbol string, lineIndex *int) { - message := fmt.Sprintf("the relation '%s' can have at most one direct relationship.", symbol) - c.addError(message, DuplicatedError, symbol, lineIndex, nil, nil) -} - -// RaiseInvalidConditionNameInParameter raises an error for invalid condition names. -func (c *ErrorCollector) RaiseInvalidConditionNameInParameter(symbol, typeName, relationName, conditionName string, - meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is not a defined condition in the model.", conditionName) - c.addError(message, ConditionNotDefined, symbol, lineIndex, meta, nil) -} - -// RaiseUnusedCondition raises an error for unused conditions. -func (c *ErrorCollector) RaiseUnusedCondition(symbol string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` condition is not used in the model.", symbol) - c.addError(message, ConditionNotUsed, symbol, lineIndex, meta, nil) -} - -// RaiseDifferentNestedConditionName raises an error for a condition whose nested -// name property differs from its map key. The message mirrors the reference. -func (c *ErrorCollector) RaiseDifferentNestedConditionName(condition, nestedConditionName string) { - message := fmt.Sprintf("condition key is `%s` but nested name property is %s", condition, nestedConditionName) - c.addError(message, DifferentNestedConditionName, nestedConditionName, nil, nil, nil) -} - -// RaiseMultipleModulesInSingleFile raises an error for multiple modules in single file. -func (c *ErrorCollector) RaiseMultipleModulesInSingleFile(file string, modules []string) { - moduleList := strings.Join(modules, ", ") - message := fmt.Sprintf("file '%s' contains multiple modules: %s.", file, moduleList) - c.addError(message, MultipleModulesInFile, file, nil, nil, nil) -} - -// Complex operation validation error methods - -// RaiseRedundantUnionMember raises an error for redundant members in union operations. -func (c *ErrorCollector) RaiseRedundantUnionMember(operation, relationName, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Redundant operation '%s' found in union for relation '%s' of type '%s'", operation, relationName, typeName) - c.addError(message, DuplicatedError, operation, lineIndex, meta, nil) -} - -// RaiseImpossibleIntersection raises an error for intersection operations that cannot succeed. -func (c *ErrorCollector) RaiseImpossibleIntersection(relationName, typeName string, conflictingTypes []string, meta *Meta, lineIndex *int) { - typeList := strings.Join(conflictingTypes, ", ") - message := fmt.Sprintf("Impossible intersection in relation '%s' of type '%s': conflicting types [%s]", relationName, typeName, typeList) - c.addError(message, InvalidRelationType, relationName, lineIndex, meta, nil) -} - -// RaiseEmptyDifference raises an error for difference operations that result in empty sets. -func (c *ErrorCollector) RaiseEmptyDifference(relationName, typeName, operation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Empty difference operation in relation '%s' of type '%s': subtracting '%s' from itself", relationName, typeName, operation) - c.addError(message, RelationNoEntrypoint, relationName, lineIndex, meta, nil) -} diff --git a/pkg/go/validation/error_collector_test.go b/pkg/go/validation/error_collector_test.go deleted file mode 100644 index d9d85ae8..00000000 --- a/pkg/go/validation/error_collector_test.go +++ /dev/null @@ -1,398 +0,0 @@ -package validation - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - - - -func TestWordIndex(t *testing.T) { - tests := []struct { - name string - rawLine string - symbol string - want int - }{ - {"empty symbol returns 0", "define viewer: [user]", "", 0}, - {"not found returns 0", "define viewer: [user]", "missing", 0}, - {"word-boundary match", "define viewer: [user]", "user", 16}, - {"prefers boundary over earlier substring", "define ownerx: owner", "owner", 15}, - {"falls back to substring when no boundary", "type usergroup", "user", 5}, - {"non-word symbol falls back to substring", "define x: [user:*]", "user:*", 11}, - {"first occurrence wins on boundary", "a or a", "a", 0}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, wordIndex(tt.rawLine, tt.symbol)) - }) - } -} - -func TestNewErrorCollector(t *testing.T) { - lines := []string{"line 1", "line 2", "line 3"} - collector := NewErrorCollector(lines) - - assert.NotNil(t, collector) - assert.Equal(t, lines, collector.lines) - assert.Equal(t, 0, collector.Count()) - assert.False(t, collector.HasErrors()) -} - -func TestErrorCollector_GetErrors(t *testing.T) { - collector := NewErrorCollector(nil) - - // Initially no errors - errors := collector.GetErrors() - assert.Empty(t, errors) - - // Add an error - collector.RaiseInvalidName("test", "rule", nil, nil, nil) - - errors = collector.GetErrors() - assert.Len(t, errors, 1) - assert.Contains(t, errors[0].Message, "test") -} - -func TestErrorCollector_HasErrors(t *testing.T) { - collector := NewErrorCollector(nil) - - assert.False(t, collector.HasErrors()) - - collector.RaiseInvalidName("test", "rule", nil, nil, nil) - - assert.True(t, collector.HasErrors()) -} - -func TestErrorCollector_Count(t *testing.T) { - collector := NewErrorCollector(nil) - - assert.Equal(t, 0, collector.Count()) - - collector.RaiseInvalidName("test1", "rule", nil, nil, nil) - assert.Equal(t, 1, collector.Count()) - - collector.RaiseInvalidName("test2", "rule", nil, nil, nil) - assert.Equal(t, 2, collector.Count()) -} - -func TestErrorCollector_RaiseInvalidName(t *testing.T) { - tests := []struct { - name string - symbol string - clause string - typeName *string - lineIndex *int - meta *Meta - expectedMsg string - expectedType ValidationErrorType - }{ - { - name: "type invalid name", - symbol: "invalid-type", - clause: "[a-zA-Z]+", - typeName: nil, - expectedMsg: "type 'invalid-type' does not match naming rule: '[a-zA-Z]+'.", - expectedType: InvalidName, - }, - { - name: "relation invalid name", - symbol: "invalid-relation", - clause: "[a-zA-Z]+", - typeName: ptrString("document"), - expectedMsg: "relation 'invalid-relation' of type 'document' does not match naming rule: '[a-zA-Z]+'.", - expectedType: InvalidName, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - collector.RaiseInvalidName(tt.symbol, tt.clause, tt.typeName, tt.lineIndex, tt.meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, tt.expectedMsg, errors[0].Message) - assert.Equal(t, tt.expectedType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.symbol, errors[0].Metadata.Symbol) - }) - } -} - -func TestErrorCollector_RaiseReservedTypeName(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} - - collector.RaiseReservedTypeName("self", &lineIndex, meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "a type cannot be named 'self' or 'this'.", errors[0].Message) - assert.Equal(t, ReservedTypeKeywords, errors[0].Metadata.ErrorType) - assert.Equal(t, "self", errors[0].Metadata.Symbol) - assert.Equal(t, "test.fga", errors[0].File) -} - -func TestErrorCollector_RaiseReservedRelationName(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 3 - meta := &Meta{File: "test.fga", Module: "test"} - - collector.RaiseReservedRelationName("this", &lineIndex, meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "a relation cannot be named 'self' or 'this'.", errors[0].Message) - assert.Equal(t, ReservedRelationKeywords, errors[0].Metadata.ErrorType) - assert.Equal(t, "this", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseTupleUsersetRequiresDirect(t *testing.T) { - lines := []string{ - "type document", - " relations", - " define viewer: user from parent", - " define admin: [user]", - } - collector := NewErrorCollector(lines) - lineIndex := 2 - meta := &Meta{File: "test.fga"} - - collector.RaiseTupleUsersetRequiresDirect("user", "document", "viewer", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`user` relation used inside from allows only direct relation.", errors[0].Message) - assert.Equal(t, TuplesetNotDirect, errors[0].Metadata.ErrorType) - assert.Equal(t, "user", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseDuplicateTypeName(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 10 - - collector.RaiseDuplicateTypeName("document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the type `document` is a duplicate.", errors[0].Message) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Equal(t, "document", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseDuplicateTypeRestriction(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga"} - lineIndex := 5 - - collector.RaiseDuplicateTypeRestriction("user", "viewer", "document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the type restriction `user` is a duplicate in the relation `viewer`.", errors[0].Message) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Equal(t, "user", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseNoEntryPointLoop(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 8 - - collector.RaiseNoEntryPointLoop("viewer", "document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`viewer` is an impossible relation for `document` (potential loop).", errors[0].Message) - assert.Equal(t, RelationNoEntrypoint, errors[0].Metadata.ErrorType) - assert.Equal(t, "viewer", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseNoEntryPoint(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 12 - - collector.RaiseNoEntryPoint("viewer", "document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`viewer` is an impossible relation for `document` (no entrypoint).", errors[0].Message) - assert.Equal(t, RelationNoEntrypoint, errors[0].Metadata.ErrorType) - assert.Equal(t, "viewer", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseInvalidType(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 3 - - collector.RaiseInvalidType("unknown_type", "document", "viewer", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`unknown_type` is not a valid type.", errors[0].Message) - assert.Equal(t, InvalidType, errors[0].Metadata.ErrorType) - assert.Equal(t, "unknown_type", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseAssignableRelationMustHaveTypes(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 6 - - collector.RaiseAssignableRelationMustHaveTypes("viewer", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the assignable relation 'viewer' must have at least one assignable type.", errors[0].Message) - assert.Equal(t, AssignableRelationsMustHaveType, errors[0].Metadata.ErrorType) - assert.Equal(t, "viewer", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseInvalidRelationError(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 4 - validRelations := []string{"admin", "viewer"} - - collector.RaiseInvalidRelationError("unknown", "document", "relation", validRelations, &lineIndex, meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the relation `unknown` does not exist.", errors[0].Message) - assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) - assert.Equal(t, "unknown", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseSchemaVersionRequired(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 0 - - collector.RaiseSchemaVersionRequired("", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "schema version required", errors[0].Message) - assert.Equal(t, SchemaVersionRequired, errors[0].Metadata.ErrorType) -} - -func TestErrorCollector_RaiseInvalidSchemaVersion(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 1 - - collector.RaiseInvalidSchemaVersion("2.0", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "invalid schema 2.0", errors[0].Message) - assert.Equal(t, InvalidSchema, errors[0].Metadata.ErrorType) - assert.Equal(t, "2.0", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseSchemaVersionUnsupported(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 1 - - collector.RaiseSchemaVersionUnsupported("1.0", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "schema version no longer supported", errors[0].Message) - assert.Equal(t, SchemaVersionUnsupported, errors[0].Metadata.ErrorType) - assert.Equal(t, "1.0", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseUnusedCondition(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 15 - - collector.RaiseUnusedCondition("unused_condition", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`unused_condition` condition is not used in the model.", errors[0].Message) - assert.Equal(t, ConditionNotUsed, errors[0].Metadata.ErrorType) - assert.Equal(t, "unused_condition", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseDifferentNestedConditionName(t *testing.T) { - collector := NewErrorCollector(nil) - - collector.RaiseDifferentNestedConditionName("condition1", "condition2") - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "condition key is `condition1` but nested name property is condition2", errors[0].Message) - assert.Equal(t, DifferentNestedConditionName, errors[0].Metadata.ErrorType) - assert.Equal(t, "condition2", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseMultipleModulesInSingleFile(t *testing.T) { - collector := NewErrorCollector(nil) - modules := []string{"module1", "module2", "module3"} - - collector.RaiseMultipleModulesInSingleFile("test.fga", modules) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "file 'test.fga' contains multiple modules: module1, module2, module3.", errors[0].Message) - assert.Equal(t, MultipleModulesInFile, errors[0].Metadata.ErrorType) - assert.Equal(t, "test.fga", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_LineAndColumnResolution(t *testing.T) { - lines := []string{ - "model", - " schema 1.1", - "type document", - " relations", - " define viewer: [user]", - } - collector := NewErrorCollector(lines) - lineIndex := 4 - - collector.RaiseInvalidName("viewer", "rule", nil, &lineIndex, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - - // Check line information - assert.NotNil(t, errors[0].Line) - assert.Equal(t, 4, errors[0].Line.Start) - assert.Equal(t, 4, errors[0].Line.End) - - // Check column information (should find "viewer" in the line) - assert.NotNil(t, errors[0].Column) - line := lines[4] - expectedStart := strings.Index(line, "viewer") - assert.Equal(t, expectedStart, errors[0].Column.Start) - assert.Equal(t, expectedStart+len("viewer"), errors[0].Column.End) -} - -func TestErrorCollector_CustomResolver(t *testing.T) { - lines := []string{ - "type document", - " relations", - " define viewer: user from parent", - } - collector := NewErrorCollector(lines) - lineIndex := 2 - meta := &Meta{File: "test.fga"} - - collector.RaiseTupleUsersetRequiresDirect("user", "document", "viewer", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - - // The custom resolver should position the error after "from" keyword - assert.NotNil(t, errors[0].Column) - line := lines[2] - fromIndex := strings.Index(line, "from") - expectedStart := fromIndex + len("from") + strings.Index(line[fromIndex+len("from"):], "user") - assert.Equal(t, expectedStart, errors[0].Column.Start) -} diff --git a/pkg/go/validation/errors.go b/pkg/go/validation/errors.go deleted file mode 100644 index 213467d1..00000000 --- a/pkg/go/validation/errors.go +++ /dev/null @@ -1,156 +0,0 @@ -package validation - -import ( - "fmt" - "strings" -) - -// ValidationErrorType represents the different types of validation errors. -type ValidationErrorType string - -const ( - SchemaVersionRequired ValidationErrorType = "schema-version-required" - SchemaVersionUnsupported ValidationErrorType = "schema-version-unsupported" - ReservedTypeKeywords ValidationErrorType = "reserved-type-keywords" - ReservedRelationKeywords ValidationErrorType = "reserved-relation-keywords" - SelfError ValidationErrorType = "self-error" - InvalidName ValidationErrorType = "invalid-name" - MissingDefinition ValidationErrorType = "missing-definition" - InvalidRelationType ValidationErrorType = "invalid-relation-type" - InvalidRelationOnTupleset ValidationErrorType = "invalid-relation-on-tupleset" - InvalidType ValidationErrorType = "invalid-type" - RelationNoEntrypoint ValidationErrorType = "relation-no-entry-point" - TuplesetNotDirect ValidationErrorType = "tupleuserset-not-direct" - DuplicatedError ValidationErrorType = "duplicated-error" - // Undefined reference errors. - UndefinedType ValidationErrorType = "undefined-type" - UndefinedRelation ValidationErrorType = "undefined-relation" - - // Cycle and entry point errors. - CyclicError ValidationErrorType = "cyclic-error" - - // Wildcard validation errors. - InvalidWildcardError ValidationErrorType = "invalid-wildcard-error" - AssignableRelationsMustHaveType ValidationErrorType = "assignable-relation-must-have-type" - InvalidSchema ValidationErrorType = "invalid-schema" - InvalidSyntax ValidationErrorType = "invalid-syntax" - TypeRestrictionCannotHaveWildcardAndRelation ValidationErrorType = "type-wildcard-relation" - ConditionNotDefined ValidationErrorType = "condition-not-defined" - ConditionNotUsed ValidationErrorType = "condition-not-used" - DifferentNestedConditionName ValidationErrorType = "different-nested-condition-name" - MultipleModulesInFile ValidationErrorType = "multiple-modules-in-file" - CyclicRelation ValidationErrorType = "cyclic-relation" - InvalidSchemaVersion ValidationErrorType = "invalid-schema-version" -) - -// LineRange represents line start and end positions. -type LineRange struct { - Start int `json:"start"` - End int `json:"end"` -} - -// ColumnRange represents column start and end positions. -type ColumnRange struct { - Start int `json:"start"` - End int `json:"end"` -} - -// ErrorMetadata contains metadata about the validation error. -type ErrorMetadata struct { - Symbol string `json:"symbol"` - ErrorType ValidationErrorType `json:"errorType"` - Module string `json:"module,omitempty"` - Type string `json:"type,omitempty"` - Relation string `json:"relation,omitempty"` - Condition string `json:"condition,omitempty"` - OffendingType string `json:"offendingType,omitempty"` -} - -// ValidationError represents a single validation error. -type ValidationError struct { - Message string `json:"msg"` - Line *LineRange `json:"line,omitempty"` - Column *ColumnRange `json:"column,omitempty"` - File string `json:"file,omitempty"` - Metadata *ErrorMetadata `json:"metadata,omitempty"` -} - -// Error implements the error interface. -func (e *ValidationError) Error() string { - location := "" - if e.Line != nil && e.Column != nil { - location = fmt.Sprintf(" at line=%d, column=%d", e.Line.Start, e.Column.Start) - } - return fmt.Sprintf("validation error%s: %s", location, e.Message) -} - -// String returns a string representation of the error. -func (e *ValidationError) String() string { - return e.Error() -} - -// ValidationErrors represents a collection of validation errors. -// -//nolint:errname // plural name intentionally describes a collection of errors -type ValidationErrors struct { - Errors []*ValidationError `json:"errors"` -} - -// Error implements the error interface for ValidationErrors. -func (e *ValidationErrors) Error() string { - if len(e.Errors) == 0 { - return "no validation errors" - } - - plural := "" - if len(e.Errors) > 1 { - plural = "s" - } - - var errorStrings []string - for _, err := range e.Errors { - errorStrings = append(errorStrings, err.String()) - } - - return fmt.Sprintf("%d error%s occurred:\n\t* %s\n\n", - len(e.Errors), plural, strings.Join(errorStrings, "\n\t* ")) -} - -// Add adds a validation error to the collection. -func (e *ValidationErrors) Add(err *ValidationError) { - e.Errors = append(e.Errors, err) -} - -// GetErrors returns a slice of all validation errors. -func (e *ValidationErrors) GetErrors() []*ValidationError { - return e.Errors -} - -// NewValidationErrors creates a new ValidationErrors instance from a slice of ValidationError -func NewValidationErrors(errors []*ValidationError) *ValidationErrors { - if errors == nil { - errors = make([]*ValidationError, 0) - } - return &ValidationErrors{ - Errors: errors, - } -} - -// HasErrors returns true if there are any errors. -func (e *ValidationErrors) HasErrors() bool { - return len(e.Errors) > 0 -} - -// Count returns the number of errors. -func (e *ValidationErrors) Count() int { - return len(e.Errors) -} - -// Meta represents file and module metadata. -type Meta struct { - File string `json:"file,omitempty"` - Module string `json:"module,omitempty"` -} - -// ErrorCustomResolver is a function type for custom error position resolution. -type ErrorCustomResolver func(wordIndex int, rawLine string, symbol string) int diff --git a/pkg/go/validation/errors_test.go b/pkg/go/validation/errors_test.go deleted file mode 100644 index c558d32e..00000000 --- a/pkg/go/validation/errors_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestValidationError_Error(t *testing.T) { - tests := []struct { - name string - error *ValidationError - expected string - }{ - { - name: "error with line and column", - error: &ValidationError{ - Message: "test error message", - Line: &LineRange{Start: 5, End: 5}, - Column: &ColumnRange{Start: 10, End: 15}, - Metadata: &ErrorMetadata{ - Symbol: "test_symbol", - ErrorType: InvalidName, - }, - }, - expected: "validation error at line=5, column=10: test error message", - }, - { - name: "error without line/column", - error: &ValidationError{ - Message: "test error message", - Metadata: &ErrorMetadata{ - Symbol: "test_symbol", - ErrorType: InvalidType, - }, - }, - expected: "validation error: test error message", - }, - { - name: "error with file", - error: &ValidationError{ - Message: "test error message", - File: "test.fga", - Line: &LineRange{Start: 3, End: 3}, - Column: &ColumnRange{Start: 0, End: 4}, - }, - expected: "validation error at line=3, column=0: test error message", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := tt.error.Error() - assert.Equal(t, tt.expected, actual) - }) - } -} - -func TestValidationError_String(t *testing.T) { - valErr := &ValidationError{ - Message: "test error", - Line: &LineRange{Start: 1, End: 1}, - Column: &ColumnRange{Start: 5, End: 10}, - } - - // String() should return the same as Error() - assert.Equal(t, valErr.Error(), valErr.String()) -} - -func TestValidationErrors_Error(t *testing.T) { - tests := []struct { - name string - errors *ValidationErrors - expected string - }{ - { - name: "no errors", - errors: &ValidationErrors{Errors: []*ValidationError{}}, - expected: "no validation errors", - }, - { - name: "single error", - errors: &ValidationErrors{ - Errors: []*ValidationError{ - {Message: "first error"}, - }, - }, - expected: "1 error occurred:\n\t* validation error: first error\n\n", - }, - { - name: "multiple errors", - errors: &ValidationErrors{ - Errors: []*ValidationError{ - {Message: "first error"}, - {Message: "second error"}, - }, - }, - expected: "2 errors occurred:\n\t* validation error: first error\n\t* validation error: second error\n\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := tt.errors.Error() - assert.Equal(t, tt.expected, actual) - }) - } -} - -func TestValidationErrors_Add(t *testing.T) { - errors := &ValidationErrors{} - - // Initially no errors - assert.False(t, errors.HasErrors()) - assert.Equal(t, 0, errors.Count()) - - // Add first error - error1 := &ValidationError{Message: "first error"} - errors.Add(error1) - - assert.True(t, errors.HasErrors()) - assert.Equal(t, 1, errors.Count()) - assert.Equal(t, error1, errors.Errors[0]) - - // Add second error - error2 := &ValidationError{Message: "second error"} - errors.Add(error2) - - assert.Equal(t, 2, errors.Count()) - assert.Equal(t, error2, errors.Errors[1]) -} - -func TestValidationErrors_HasErrors(t *testing.T) { - errors := &ValidationErrors{} - assert.False(t, errors.HasErrors()) - - errors.Add(&ValidationError{Message: "test"}) - assert.True(t, errors.HasErrors()) -} - -func TestValidationErrors_Count(t *testing.T) { - errors := &ValidationErrors{} - assert.Equal(t, 0, errors.Count()) - - errors.Add(&ValidationError{Message: "test1"}) - assert.Equal(t, 1, errors.Count()) - - errors.Add(&ValidationError{Message: "test2"}) - assert.Equal(t, 2, errors.Count()) -} - -func TestErrorMetadata(t *testing.T) { - metadata := &ErrorMetadata{ - Symbol: "test_symbol", - ErrorType: InvalidName, - Module: "test_module", - Type: "test_type", - Relation: "test_relation", - Condition: "test_condition", - OffendingType: "offending_type", - } - - assert.Equal(t, "test_symbol", metadata.Symbol) - assert.Equal(t, InvalidName, metadata.ErrorType) - assert.Equal(t, "test_module", metadata.Module) - assert.Equal(t, "test_type", metadata.Type) - assert.Equal(t, "test_relation", metadata.Relation) - assert.Equal(t, "test_condition", metadata.Condition) - assert.Equal(t, "offending_type", metadata.OffendingType) -} - -func TestLineRange(t *testing.T) { - line := &LineRange{Start: 5, End: 10} - assert.Equal(t, 5, line.Start) - assert.Equal(t, 10, line.End) -} - -func TestColumnRange(t *testing.T) { - column := &ColumnRange{Start: 15, End: 25} - assert.Equal(t, 15, column.Start) - assert.Equal(t, 25, column.End) -} - -func TestValidationErrorTypes(t *testing.T) { - // Test that all validation error types are defined as expected - errorTypes := []ValidationErrorType{ - SchemaVersionRequired, - SchemaVersionUnsupported, - ReservedTypeKeywords, - ReservedRelationKeywords, - SelfError, - InvalidName, - MissingDefinition, - InvalidRelationType, - InvalidRelationOnTupleset, - InvalidType, - RelationNoEntrypoint, - TuplesetNotDirect, - DuplicatedError, - AssignableRelationsMustHaveType, - InvalidSchema, - InvalidSyntax, - TypeRestrictionCannotHaveWildcardAndRelation, - ConditionNotDefined, - ConditionNotUsed, - DifferentNestedConditionName, - MultipleModulesInFile, - } - - // Ensure all error types have string values - for _, errorType := range errorTypes { - assert.NotEmpty(t, string(errorType)) - assert.Contains(t, string(errorType), "-") - } - - // Test specific error type values match JS implementation - assert.Equal(t, "schema-version-required", string(SchemaVersionRequired)) - assert.Equal(t, "missing-definition", string(MissingDefinition)) - assert.Equal(t, "reserved-type-keywords", string(ReservedTypeKeywords)) - assert.Equal(t, "relation-no-entry-point", string(RelationNoEntrypoint)) -} - -func TestMeta(t *testing.T) { - meta := &Meta{ - File: "test.fga", - Module: "test_module", - } - - assert.Equal(t, "test.fga", meta.File) - assert.Equal(t, "test_module", meta.Module) -} diff --git a/pkg/go/validation/findings.go b/pkg/go/validation/findings.go new file mode 100644 index 00000000..66278307 --- /dev/null +++ b/pkg/go/validation/findings.go @@ -0,0 +1,159 @@ +// Package validation checks OpenFGA authorization models for semantic errors +// beyond what the DSL grammar enforces, mirroring the checks in pkg/js and +// pkg/java. The three implementations share the corpus under tests/data, which +// pins each finding's message, code, symbol and position. +package validation + +import ( + "errors" + "fmt" +) + +// Kind is a finding's machine-readable code, the wire `errorType`. The string +// values are shared with pkg/js and pkg/java, so they are fixed. +type Kind string + +const ( + SchemaVersionRequired Kind = "schema-version-required" + SchemaVersionUnsupported Kind = "schema-version-unsupported" + ReservedTypeKeywords Kind = "reserved-type-keywords" + ReservedRelationKeywords Kind = "reserved-relation-keywords" + SelfError Kind = "self-error" + InvalidName Kind = "invalid-name" + MissingDefinition Kind = "missing-definition" + InvalidRelationType Kind = "invalid-relation-type" + InvalidRelationOnTupleset Kind = "invalid-relation-on-tupleset" + InvalidType Kind = "invalid-type" + RelationNoEntrypoint Kind = "relation-no-entry-point" + TuplesetNotDirect Kind = "tupleuserset-not-direct" + DuplicatedError Kind = "duplicated-error" + UndefinedType Kind = "undefined-type" + UndefinedRelation Kind = "undefined-relation" + CyclicError Kind = "cyclic-error" + InvalidWildcardError Kind = "invalid-wildcard-error" + + AssignableRelationsMustHaveType Kind = "assignable-relation-must-have-type" + InvalidSchema Kind = "invalid-schema" + InvalidSyntax Kind = "invalid-syntax" + TypeRestrictionCannotHaveWildcardAndRelation Kind = "type-wildcard-relation" + ConditionNotDefined Kind = "condition-not-defined" + ConditionNotUsed Kind = "condition-not-used" + DifferentNestedConditionName Kind = "different-nested-condition-name" + MultipleModulesInFile Kind = "multiple-modules-in-file" + CyclicRelation Kind = "cyclic-relation" + InvalidSchemaVersion Kind = "invalid-schema-version" +) + +// Range is a start and end position in the source text, used for both the line +// and the column a finding is at. +// +// The two are indexed differently: a line Range repeats the same zero-based +// index in Start and End, while a column Range is half-open, End being one past +// the symbol's last character. +type Range struct { + Start int `json:"start"` + End int `json:"end"` +} + +// Metadata is the structured half of a finding: the offending symbol, the code, +// and which part of the model the finding is about. The field names and JSON +// shape match pkg/js and pkg/java. +type Metadata struct { + Symbol string `json:"symbol"` + Kind Kind `json:"errorType"` + Module string `json:"module,omitempty"` + Type string `json:"type,omitempty"` + Relation string `json:"relation,omitempty"` + Condition string `json:"condition,omitempty"` + OffendingType string `json:"offendingType,omitempty"` +} + +// Finding is one validation diagnostic. Its fields are the cross-language wire +// shape, so it marshals without any custom JSON code. +// +// Line and Column are nil when the finding could not be located in the source, +// which is always the case for a model validated from JSON. +// +//nolint:errname // named for what it is, as go/scanner.Error is +type Finding struct { + Message string `json:"msg"` + Line *Range `json:"line,omitempty"` + Column *Range `json:"column,omitempty"` + File string `json:"file,omitempty"` + Metadata Metadata `json:"metadata"` +} + +// Error implements the error interface, formatting the finding with its +// position when it has one. +func (f *Finding) Error() string { + if f.Line != nil && f.Column != nil { + return fmt.Sprintf("validation error at line=%d, column=%d: %s", f.Line.Start, f.Column.Start, f.Message) + } + + return "validation error: " + f.Message +} + +// in records the file and module a finding was raised about, read off the +// model's source info by whichever loop raised it. Chainable; nil-safe. +func (f *Finding) in(file, module string) *Finding { + if f == nil { + return nil + } + f.File, f.Metadata.Module = file, module + + return f +} + +// joinFindings joins findings into a single error, or nil when there are none. +// A nil *Finding is dropped, so a check that found nothing can be appended +// without a guard. The findings are joined in the order given, and ExtractAllAs +// recovers them in that order. +func joinFindings(findings ...*Finding) error { + errs := make([]error, 0, len(findings)) + for _, f := range findings { + if f != nil { + errs = append(errs, f) + } + } + + return errors.Join(errs...) +} + +// ExtractAllAs walks an error tree and returns every error of type E, in the +// order errors.Join holds them: pre-order, left to right. It recovers the +// findings behind a validation error: +// +// for _, finding := range validation.ExtractAllAs[*validation.Finding](err) { +// ... +// } +// +// An error that is an E is collected and not descended into; anything else is +// expanded through its Unwrap() []error or Unwrap() error. +func ExtractAllAs[E error](err error) []E { + var found []E + + var collect func(error) + collect = func(err error) { + if err == nil { + return + } + + if e, ok := err.(E); ok { + found = append(found, e) + + return + } + + switch unwrapped := err.(type) { + case interface{ Unwrap() []error }: + for _, child := range unwrapped.Unwrap() { + collect(child) + } + case interface{ Unwrap() error }: + collect(unwrapped.Unwrap()) + } + } + collect(err) + + return found +} diff --git a/pkg/go/validation/findings_test.go b/pkg/go/validation/findings_test.go new file mode 100644 index 00000000..70534f3b --- /dev/null +++ b/pkg/go/validation/findings_test.go @@ -0,0 +1,155 @@ +package validation + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindingError(t *testing.T) { + t.Parallel() + + t.Run("with position", func(t *testing.T) { + t.Parallel() + + finding := &Finding{ + Message: "the relation `viewer` does not exist.", + Line: &Range{Start: 4, End: 4}, + Column: &Range{Start: 12, End: 18}, + } + + assert.Equal(t, "validation error at line=4, column=12: the relation `viewer` does not exist.", finding.Error()) + }) + + t.Run("without position", func(t *testing.T) { + t.Parallel() + + finding := &Finding{Message: "schema version required"} + + assert.Equal(t, "validation error: schema version required", finding.Error()) + }) +} + +func TestFindingIn(t *testing.T) { + t.Parallel() + + t.Run("stamps file and module", func(t *testing.T) { + t.Parallel() + + finding := (&Finding{}).in("core.fga", "core") + + assert.Equal(t, "core.fga", finding.File) + assert.Equal(t, "core", finding.Metadata.Module) + }) + + t.Run("nil finding stays nil", func(t *testing.T) { + t.Parallel() + + var finding *Finding + assert.Nil(t, finding.in("core.fga", "core")) + }) +} + +func TestJoinFindings(t *testing.T) { + t.Parallel() + + t.Run("nil when there is nothing to report", func(t *testing.T) { + t.Parallel() + + require.NoError(t, joinFindings()) + require.NoError(t, joinFindings(nil, nil)) + }) + + t.Run("drops nil findings", func(t *testing.T) { + t.Parallel() + + err := joinFindings(nil, &Finding{Message: "boom"}, nil) + require.Error(t, err) + + found := ExtractAllAs[*Finding](err) + require.Len(t, found, 1) + assert.Equal(t, "boom", found[0].Message) + }) + + t.Run("keeps the order it is given", func(t *testing.T) { + t.Parallel() + + err := joinFindings(&Finding{Message: "first"}, &Finding{Message: "second"}) + + found := ExtractAllAs[*Finding](err) + require.Len(t, found, 2) + assert.Equal(t, "first", found[0].Message) + assert.Equal(t, "second", found[1].Message) + }) +} + +func TestExtractAllAs(t *testing.T) { + t.Parallel() + + t.Run("nil error yields nothing", func(t *testing.T) { + t.Parallel() + + assert.Empty(t, ExtractAllAs[*Finding](nil)) + }) + + t.Run("recovers findings from a nested tree in order", func(t *testing.T) { + t.Parallel() + + // A join of joins, the shape validate builds from its phases. + left := joinFindings(&Finding{Message: "1"}, &Finding{Message: "2"}) + right := joinFindings(&Finding{Message: "3"}, &Finding{Message: "4"}) + + found := ExtractAllAs[*Finding](errors.Join(left, right)) + require.Len(t, found, 4) + assert.Equal(t, []string{"1", "2", "3", "4"}, []string{ + found[0].Message, found[1].Message, found[2].Message, found[3].Message, + }) + }) + + t.Run("terminates on a leaf that is neither the target nor a wrapper", func(t *testing.T) { + t.Parallel() + + // errors.New has neither an Unwrap() []error nor an Unwrap() error, so + // the walk has nothing to descend into and ends its branch there. + found := ExtractAllAs[*Finding](errors.Join(&Finding{Message: "boom"}, errors.New("unrelated"))) + require.Len(t, found, 1) + assert.Equal(t, "boom", found[0].Message) + }) + + t.Run("descends through a single-error wrapper", func(t *testing.T) { + t.Parallel() + + found := ExtractAllAs[*Finding](fmt.Errorf("context: %w", &Finding{Message: "wrapped"})) + require.Len(t, found, 1) + assert.Equal(t, "wrapped", found[0].Message) + }) +} + +// TestFindingErrorsInterop pins that the standard errors helpers still reach a +// finding, so a caller that only wants the first is not forced through +// ExtractAllAs. +func TestFindingErrorsInterop(t *testing.T) { + t.Parallel() + + first := &Finding{Message: "first", Metadata: Metadata{Kind: InvalidName}} + second := &Finding{Message: "second", Metadata: Metadata{Kind: DuplicatedError}} + err := joinFindings(first, second) + + t.Run("errors.As reaches the first finding", func(t *testing.T) { + t.Parallel() + + var finding *Finding + require.ErrorAs(t, err, &finding) + assert.Same(t, first, finding) + }) + + t.Run("errors.Is matches each finding", func(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, err, error(first)) + require.ErrorIs(t, err, error(second)) + }) +} diff --git a/pkg/go/validation/index.go b/pkg/go/validation/index.go new file mode 100644 index 00000000..72b3cdde --- /dev/null +++ b/pkg/go/validation/index.go @@ -0,0 +1,122 @@ +package validation + +import ( + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +// index is the model indexed for lookup: types and relations by name. It is +// built once per validation run and shared by every phase that resolves +// references, so each phase does not rebuild its own maps. +// +// When a type is declared more than once the maps are built last-wins, matching +// the reference's typeMap; the duplicate itself is reported by the duplicates +// phase. +type index struct { + model *openfgav1.AuthorizationModel + types map[string]*openfgav1.TypeDefinition + relations map[string]map[string]*openfgav1.Userset +} + +func newIndex(model *openfgav1.AuthorizationModel) *index { + idx := &index{ + model: model, + types: make(map[string]*openfgav1.TypeDefinition, len(model.GetTypeDefinitions())), + relations: make(map[string]map[string]*openfgav1.Userset, len(model.GetTypeDefinitions())), + } + + for _, typeDef := range model.GetTypeDefinitions() { + idx.types[typeDef.GetType()] = typeDef + + if relations := typeDef.GetRelations(); len(relations) > 0 { + byName := make(map[string]*openfgav1.Userset, len(relations)) + for relationName, userset := range relations { + byName[relationName] = userset + } + + idx.relations[typeDef.GetType()] = byName + } + } + + return idx +} + +func (idx *index) typeDefined(typeName string) bool { + _, ok := idx.types[typeName] + + return ok +} + +func (idx *index) relationDefined(typeName, relationName string) bool { + _, ok := idx.relations[typeName][relationName] + + return ok +} + +func (idx *index) typeDef(typeName string) *openfgav1.TypeDefinition { + return idx.types[typeName] +} + +func (idx *index) userset(typeName, relationName string) *openfgav1.Userset { + return idx.relations[typeName][relationName] +} + +// directTypeRestrictions returns the directly-related user types declared for a +// relation in its metadata. +func (idx *index) directTypeRestrictions(typeName, relationName string) []*openfgav1.RelationReference { + typeDef := idx.types[typeName] + if typeDef == nil { + return nil + } + + relationMetadata, ok := typeDef.GetMetadata().GetRelations()[relationName] + if !ok { + return nil + } + + return relationMetadata.GetDirectlyRelatedUserTypes() +} + +// directlyAssignableTypes returns the type restrictions a relation is directly +// assignable to, but only when that relation is a single direct assignment +// (i.e. `define r: [a, b]` rather than a rewrite). The bool reports whether the +// relation is such a single direct assignment. This mirrors the reference +// implementation's allowableTypes helper used for tuple-to-userset validation. +func (idx *index) directlyAssignableTypes(typeName, relationName string) ([]*openfgav1.RelationReference, bool) { + userset := idx.userset(typeName, relationName) + if userset == nil { + return nil, false + } + + if _, ok := userset.GetUserset().(*openfgav1.Userset_This); !ok { + return nil, false + } + + return idx.directTypeRestrictions(typeName, relationName), true +} + +// typeMeta resolves the file and module a type was declared in. +func typeMeta(typeDef *openfgav1.TypeDefinition) (file, module string) { + return typeDef.GetMetadata().GetSourceInfo().GetFile(), typeDef.GetMetadata().GetModule() +} + +// relationMeta resolves the file and module for a relation, falling back to its +// type's for whichever of the two the relation leaves unset. +func relationMeta(typeDef *openfgav1.TypeDefinition, relationName string) (file, module string) { + relationMetadata, ok := typeDef.GetMetadata().GetRelations()[relationName] + if !ok { + return typeMeta(typeDef) + } + + file = relationMetadata.GetSourceInfo().GetFile() + module = relationMetadata.GetModule() + + if file == "" { + file = typeDef.GetMetadata().GetSourceInfo().GetFile() + } + + if module == "" { + module = typeDef.GetMetadata().GetModule() + } + + return file, module +} diff --git a/pkg/go/validation/json_corpus_test.go b/pkg/go/validation/json_corpus_test.go new file mode 100644 index 00000000..83a60342 --- /dev/null +++ b/pkg/go/validation/json_corpus_test.go @@ -0,0 +1,63 @@ +package validation + +import ( + "os" + "path/filepath" + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "gopkg.in/yaml.v3" +) + +// jsonCorpusCase is one case from tests/data/json-validation-cases.yaml, the corpus for +// models supplied as JSON. A JSON model carries no source text to resolve a position +// against, so no case in it states a line or a column. +type jsonCorpusCase struct { + Name string `yaml:"name"` + JSON string `yaml:"json"` + Skip bool `yaml:"skip,omitempty"` + ExpectedErrors []YAMLExpectedError `yaml:"expected_errors,omitempty"` +} + +// TestJSONValidationCorpus runs the JSON corpus against ValidateJSON. The JS and Java +// validators consume the same file, so a rule only a JSON model can reach — no +// position, schema checks on hand-built models — is pinned in all three SDKs. +func TestJSONValidationCorpus(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(filepath.Join(corpusDir, "json-validation-cases.yaml")) + require.NoError(t, err, "the corpus must be readable; a missing file is not a pass") + + var cases []jsonCorpusCase + require.NoError(t, yaml.Unmarshal(data, &cases)) + require.NotEmpty(t, cases, "an empty corpus would assert nothing") + + for _, testCase := range cases { + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + + if testCase.Skip { + t.Skip("the corpus marks this case skipped") + } + + model := &openfgav1.AuthorizationModel{} + + // DiscardUnknown: the corpus is shared with implementations whose model + // type may have fields this one does not, and an unknown field is not what + // a case is testing. + require.NoError(t, + protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal([]byte(testCase.JSON), model), + "the case's JSON must parse as an authorization model") + + result := compareWithCorpus(testCase.ExpectedErrors, findingsOf(ValidateJSON(model))) + + for _, problem := range result.Problems { + t.Error(problem) + } + + require.Equal(t, corpusPass, result.Status) + }) + } +} diff --git a/pkg/go/validation/keywords.go b/pkg/go/validation/keywords.go deleted file mode 100644 index 4299170c..00000000 --- a/pkg/go/validation/keywords.go +++ /dev/null @@ -1,29 +0,0 @@ -package validation - -// Reserved keywords that cannot be used as type or relation names. -const ( - KeywordSelf = "self" - KeywordDefine = "DEFINE" - KeywordThis = "this" -) - -// ReservedKeywords contains all reserved keywords. -var ReservedKeywords = map[string]bool{ - KeywordSelf: true, - KeywordThis: true, -} - -// IsReservedKeyword checks if a given string is a reserved keyword. -func IsReservedKeyword(keyword string) bool { - return ReservedKeywords[keyword] -} - -// IsReservedTypeName checks if a type name is reserved. -func IsReservedTypeName(typeName string) bool { - return IsReservedKeyword(typeName) -} - -// IsReservedRelationName checks if a relation name is reserved. -func IsReservedRelationName(relationName string) bool { - return IsReservedKeyword(relationName) -} diff --git a/pkg/go/validation/keywords_test.go b/pkg/go/validation/keywords_test.go deleted file mode 100644 index ff92a8cf..00000000 --- a/pkg/go/validation/keywords_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestKeywordConstants(t *testing.T) { - // Test that keyword constants are defined correctly - assert.Equal(t, "self", KeywordSelf) - assert.Equal(t, "DEFINE", KeywordDefine) - assert.Equal(t, "this", KeywordThis) -} - -func TestReservedKeywords(t *testing.T) { - // Test that reserved keywords map is properly initialized - assert.NotNil(t, ReservedKeywords) - - // Test that all expected keywords are in the map - assert.True(t, ReservedKeywords[KeywordSelf]) - assert.True(t, ReservedKeywords[KeywordThis]) - - // Test that non-reserved words are not in the map - assert.False(t, ReservedKeywords["document"]) - assert.False(t, ReservedKeywords["user"]) - assert.False(t, ReservedKeywords["viewer"]) -} - -func TestIsReservedKeyword(t *testing.T) { - tests := []struct { - name string - keyword string - expected bool - }{ - { - name: "self is reserved", - keyword: "self", - expected: true, - }, - { - name: "this is reserved", - keyword: "this", - expected: true, - }, - { - name: "document is not reserved", - keyword: "document", - expected: false, - }, - { - name: "user is not reserved", - keyword: "user", - expected: false, - }, - { - name: "viewer is not reserved", - keyword: "viewer", - expected: false, - }, - { - name: "admin is not reserved", - keyword: "admin", - expected: false, - }, - { - name: "empty string is not reserved", - keyword: "", - expected: false, - }, - { - name: "SELF (uppercase) is not reserved", - keyword: "SELF", - expected: false, - }, - { - name: "THIS (uppercase) is not reserved", - keyword: "THIS", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsReservedKeyword(tt.keyword) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsReservedTypeName(t *testing.T) { - tests := []struct { - name string - typeName string - expected bool - }{ - { - name: "self is reserved type name", - typeName: "self", - expected: true, - }, - { - name: "this is reserved type name", - typeName: "this", - expected: true, - }, - { - name: "document is not reserved type name", - typeName: "document", - expected: false, - }, - { - name: "user is not reserved type name", - typeName: "user", - expected: false, - }, - { - name: "folder is not reserved type name", - typeName: "folder", - expected: false, - }, - { - name: "group is not reserved type name", - typeName: "group", - expected: false, - }, - { - name: "empty string is not reserved type name", - typeName: "", - expected: false, - }, - { - name: "SELF (uppercase) is not reserved type name", - typeName: "SELF", - expected: false, - }, - { - name: "THIS (uppercase) is not reserved type name", - typeName: "THIS", - expected: false, - }, - { - name: "Self (mixed case) is not reserved type name", - typeName: "Self", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsReservedTypeName(tt.typeName) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsReservedRelationName(t *testing.T) { - tests := []struct { - name string - relationName string - expected bool - }{ - { - name: "self is reserved relation name", - relationName: "self", - expected: true, - }, - { - name: "this is reserved relation name", - relationName: "this", - expected: true, - }, - { - name: "viewer is not reserved relation name", - relationName: "viewer", - expected: false, - }, - { - name: "admin is not reserved relation name", - relationName: "admin", - expected: false, - }, - { - name: "owner is not reserved relation name", - relationName: "owner", - expected: false, - }, - { - name: "member is not reserved relation name", - relationName: "member", - expected: false, - }, - { - name: "can_view is not reserved relation name", - relationName: "can_view", - expected: false, - }, - { - name: "empty string is not reserved relation name", - relationName: "", - expected: false, - }, - { - name: "SELF (uppercase) is not reserved relation name", - relationName: "SELF", - expected: false, - }, - { - name: "THIS (uppercase) is not reserved relation name", - relationName: "THIS", - expected: false, - }, - { - name: "This (mixed case) is not reserved relation name", - relationName: "This", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsReservedRelationName(tt.relationName) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestReservedKeywordsConsistency(t *testing.T) { - // Test that IsReservedKeyword, IsReservedTypeName, and IsReservedRelationName - // are consistent with each other for reserved keywords - - reservedKeywords := []string{"self", "this"} - - for _, keyword := range reservedKeywords { - assert.True(t, IsReservedKeyword(keyword), "IsReservedKeyword should return true for %s", keyword) - assert.True(t, IsReservedTypeName(keyword), "IsReservedTypeName should return true for %s", keyword) - assert.True(t, IsReservedRelationName(keyword), "IsReservedRelationName should return true for %s", keyword) - } - - // Test that they're consistent for non-reserved words - nonReservedWords := []string{"document", "user", "viewer", "admin", "owner"} - - for _, word := range nonReservedWords { - assert.False(t, IsReservedKeyword(word), "IsReservedKeyword should return false for %s", word) - assert.False(t, IsReservedTypeName(word), "IsReservedTypeName should return false for %s", word) - assert.False(t, IsReservedRelationName(word), "IsReservedRelationName should return false for %s", word) - } -} - -func TestReservedKeywordsMatchJSImplementation(t *testing.T) { - // Test that our reserved keywords match exactly with the JS implementation - // Based on the JS keywords.ts file: - // - Keyword.SELF = "self" - // - ReservedKeywords.THIS = "this" - - // Test exact keyword values - assert.Equal(t, "self", KeywordSelf) - assert.Equal(t, "this", KeywordThis) - - // Test that these are the only reserved keywords for types and relations - assert.True(t, IsReservedTypeName("self")) - assert.True(t, IsReservedTypeName("this")) - assert.True(t, IsReservedRelationName("self")) - assert.True(t, IsReservedRelationName("this")) - - // Test case sensitivity (JS implementation is case-sensitive) - assert.False(t, IsReservedTypeName("SELF")) - assert.False(t, IsReservedTypeName("Self")) - assert.False(t, IsReservedTypeName("THIS")) - assert.False(t, IsReservedTypeName("This")) -} - -func TestReservedKeywordsValidation(t *testing.T) { - collector := NewErrorCollector(nil) - - // Test type name validation - should pass for valid names - isValid := ValidateTypeName("document", collector, nil, nil) - assert.True(t, isValid) - assert.Empty(t, collector.GetErrors()) - - // Test type name validation - should fail for reserved keywords - collector = NewErrorCollector(nil) - isValid = ValidateTypeName("this", collector, nil, nil) - assert.False(t, isValid) - assert.NotEmpty(t, collector.GetErrors()) - - // Test relation name validation - should pass for valid names - collector = NewErrorCollector(nil) - isValid = ValidateRelationName("viewer", "document", collector, nil, nil) - assert.True(t, isValid) - assert.Empty(t, collector.GetErrors()) - - // Test relation name validation - should fail for reserved keywords - collector = NewErrorCollector(nil) - isValid = ValidateRelationName("self", "document", collector, nil, nil) - assert.False(t, isValid) - assert.NotEmpty(t, collector.GetErrors()) -} diff --git a/pkg/go/validation/messages.go b/pkg/go/validation/messages.go new file mode 100644 index 00000000..69345161 --- /dev/null +++ b/pkg/go/validation/messages.go @@ -0,0 +1,259 @@ +package validation + +import ( + "fmt" + "strings" +) + +// The functions here build one finding each: the corpus-pinned message, the +// code, and which part of the model is at fault — everything the raise site +// knows about *what* went wrong. Where it is in the source (position, file, +// module) is stamped by the raise site through at and in, which is all a +// finding built from a JSON model, having no source text, goes without. + +// invalidTypeName reports a type name that breaks the naming rule. +func invalidTypeName(name string) *Finding { + return &Finding{ + Message: fmt.Sprintf("type '%s' does not match naming rule: '%s'.", name, typeNameRule), + Metadata: Metadata{Kind: InvalidName, Symbol: name, Type: name}, + } +} + +// invalidRelationName reports a relation name that breaks the naming rule. +func invalidRelationName(name, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", name, typeName, relationNameRule), + Metadata: Metadata{Kind: InvalidName, Symbol: name, Type: typeName, Relation: name}, + } +} + +// invalidConditionName reports a condition name that breaks the naming rule. +func invalidConditionName(name string) *Finding { + return &Finding{ + Message: fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", name, conditionNameRule), + Metadata: Metadata{Kind: InvalidName, Symbol: name, Condition: name}, + } +} + +// reservedTypeName reports a type named with a reserved keyword. +func reservedTypeName(name string) *Finding { + return &Finding{ + Message: "a type cannot be named 'self' or 'this'.", + Metadata: Metadata{Kind: ReservedTypeKeywords, Symbol: name, Type: name}, + } +} + +// reservedRelationName reports a relation named with a reserved keyword. +func reservedRelationName(name, typeName string) *Finding { + return &Finding{ + Message: "a relation cannot be named 'self' or 'this'.", + Metadata: Metadata{Kind: ReservedRelationKeywords, Symbol: name, Type: typeName, Relation: name}, + } +} + +// tupleUsersetRequiresDirect reports a tuple-to-userset whose tupleset relation +// is not a direct assignment. Stamped with atFromClause so the column marks the +// relation after the `from` keyword. +func tupleUsersetRequiresDirect(fromRelation, typeName, relation string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` relation used inside from allows only direct relation.", fromRelation), + Metadata: Metadata{Kind: TuplesetNotDirect, Symbol: fromRelation, Type: typeName, Relation: relation}, + } +} + +// duplicateTypeName reports a duplicated type. +func duplicateTypeName(name string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the type `%s` is a duplicate.", name), + Metadata: Metadata{Kind: DuplicatedError, Symbol: name, Type: name}, + } +} + +// duplicateTypeRestriction reports a duplicated type restriction on a relation. +func duplicateTypeRestriction(restriction, relationName, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the type restriction `%s` is a duplicate in the relation `%s`.", restriction, relationName), + Metadata: Metadata{Kind: DuplicatedError, Symbol: restriction, Type: typeName, Relation: relationName}, + } +} + +// duplicatePartialRelation reports a duplicated partial relation definition. +func duplicatePartialRelation(operand, relationName, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the partial relation definition `%s` is a duplicate in the relation `%s`.", operand, relationName), + Metadata: Metadata{Kind: DuplicatedError, Symbol: operand, Type: typeName, Relation: relationName}, + } +} + +// undefinedType reports a reference to a type that does not exist. The metadata +// names the type that is missing, not the relation it was referenced from. +func undefinedType(typeName, relationName, parentTypeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("Type '%s' is not defined (referenced in relation '%s' of type '%s')", typeName, relationName, parentTypeName), + Metadata: Metadata{Kind: UndefinedType, Symbol: typeName, Type: typeName}, + } +} + +// noEntryPointLoop reports an impossible relation with a potential loop. +func noEntryPointLoop(relation, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is an impossible relation for `%s` (potential loop).", relation, typeName), + Metadata: Metadata{Kind: RelationNoEntrypoint, Symbol: relation, Type: typeName, Relation: relation}, + } +} + +// noEntryPoint reports an impossible relation with no entry point. +func noEntryPoint(relation, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is an impossible relation for `%s` (no entrypoint).", relation, typeName), + Metadata: Metadata{Kind: RelationNoEntrypoint, Symbol: relation, Type: typeName, Relation: relation}, + } +} + +// invalidRelationOnTupleset reports a tuple-to-userset whose computed relation +// does not exist on the type the tupleset relation is assignable to. The +// tupleset relation is parent; the finding is about relationName on typeDef. +func invalidRelationOnTupleset(symbol, missingRelation, typeDef, parent, targetType, relationName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the `%s` relation definition on type `%s` is not valid: `%s` does not exist on `%s`, which is of type `%s`.", + missingRelation, typeDef, missingRelation, parent, targetType), + Metadata: Metadata{Kind: InvalidRelationOnTupleset, Symbol: symbol, Type: typeDef, Relation: relationName}, + } +} + +// invalidTypeRelation reports a relation reference that is not valid for a +// type. The offendingType is the enclosing type the reference was written in. +func invalidTypeRelation(symbol, typeName, relationName, offendingRelation, offendingType string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is not a valid relation for `%s`.", offendingRelation, typeName), + Metadata: Metadata{Kind: InvalidRelationType, Symbol: symbol, Type: typeName, Relation: relationName, OffendingType: offendingType}, + } +} + +// invalidType reports an invalid type in an assignable-types list. Stamped with +// atRestriction so the column marks the type, not a relation key sharing its +// name. +func invalidType(typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is not a valid type.", typeName), + Metadata: Metadata{Kind: InvalidType, Symbol: typeName, Type: typeName}, + } +} + +// missingRelation reports a rewrite that names a relation the type does not +// define. +func missingRelation(name, typeName, relation string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the relation `%s` does not exist.", name), + Metadata: Metadata{Kind: MissingDefinition, Symbol: name, Type: typeName, Relation: relation}, + } +} + +// invalidSchemaVersion reports a schema version that was never valid (e.g. +// "0.9", "2.0"), as distinct from one that is recognized but no longer +// supported. +func invalidSchemaVersion(version string) *Finding { + return &Finding{ + Message: "invalid schema " + version, + Metadata: Metadata{Kind: InvalidSchema, Symbol: version}, + } +} + +// schemaVersionUnsupported reports a recognized but retired schema version +// (e.g. "1.0"). +func schemaVersionUnsupported(version string) *Finding { + return &Finding{ + Message: "schema version no longer supported", + Metadata: Metadata{Kind: SchemaVersionUnsupported, Symbol: version}, + } +} + +// schemaVersionRequired reports a model with no schema version. +func schemaVersionRequired() *Finding { + return &Finding{ + Message: "schema version required", + Metadata: Metadata{Kind: SchemaVersionRequired}, + } +} + +// conditionNotDefined reports a reference to a condition the model does not +// define. +func conditionNotDefined(condition, typeName, relationName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is not a defined condition in the model.", condition), + Metadata: Metadata{Kind: ConditionNotDefined, Symbol: condition, Type: typeName, Relation: relationName, Condition: condition}, + } +} + +// unusedCondition reports a condition defined but never referenced. +func unusedCondition(condition string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` condition is not used in the model.", condition), + Metadata: Metadata{Kind: ConditionNotUsed, Symbol: condition, Condition: condition}, + } +} + +// differentNestedConditionName reports a condition whose nested name property +// differs from its map key. It carries no position, matching the reference. +func differentNestedConditionName(conditionKey, nestedName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("condition key is `%s` but nested name property is %s", conditionKey, nestedName), + Metadata: Metadata{Kind: DifferentNestedConditionName, Symbol: nestedName, Condition: conditionKey}, + } +} + +// multipleModulesInSingleFile reports a file that would contain more than one +// module. It carries no position: it is about the file, not a line in it. +func multipleModulesInSingleFile(file string, modules []string) *Finding { + return &Finding{ + Message: fmt.Sprintf("file %s would contain multiple module definitions (%s) when transforming to DSL. "+ + "Only one module can be defined per file.", file, strings.Join(modules, ", ")), + Metadata: Metadata{Kind: MultipleModulesInFile, Symbol: file}, + } +} + +// redundantUnionMember reports a repeated member in a union. +func redundantUnionMember(operation, relationName, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("Redundant operation '%s' found in union for relation '%s' of type '%s'", operation, relationName, typeName), + Metadata: Metadata{Kind: DuplicatedError, Symbol: operation, Type: typeName, Relation: relationName}, + } +} + +// impossibleIntersection reports an intersection that cannot be satisfied. +func impossibleIntersection(relationName, typeName string, conflictingTypes []string) *Finding { + return &Finding{ + Message: fmt.Sprintf("Impossible intersection in relation '%s' of type '%s': conflicting types [%s]", + relationName, typeName, strings.Join(conflictingTypes, ", ")), + Metadata: Metadata{Kind: InvalidRelationType, Symbol: relationName, Type: typeName, Relation: relationName}, + } +} + +// emptyDifference reports a difference that subtracts an operand from itself. +func emptyDifference(relationName, typeName, operation string) *Finding { + return &Finding{ + Message: fmt.Sprintf("Empty difference operation in relation '%s' of type '%s': subtracting '%s' from itself", + relationName, typeName, operation), + Metadata: Metadata{Kind: RelationNoEntrypoint, Symbol: relationName, Type: typeName, Relation: relationName}, + } +} + +// invalidWildcardUsage reports a wildcard used where it is not allowed. The +// wildcard restricts typeName inside a relation of parentTypeName. +func invalidWildcardUsage(typeName, relationName, parentTypeName, reason string) *Finding { + return &Finding{ + Message: fmt.Sprintf("Invalid wildcard usage for type '%s' in relation '%s' of type '%s': %s", + typeName, relationName, parentTypeName, reason), + Metadata: Metadata{Kind: InvalidWildcardError, Symbol: typeName, Type: parentTypeName, Relation: relationName}, + } +} + +// tuplesetNotDirect reports a tupleset relation that does not allow direct +// assignment. +func tuplesetNotDirect(tuplesetRelation, typeName, parentRelation string) *Finding { + return &Finding{ + Message: fmt.Sprintf("Tupleset relation '%s' on type '%s' must allow direct assignment (used in relation '%s')", + tuplesetRelation, typeName, parentRelation), + Metadata: Metadata{Kind: TuplesetNotDirect, Symbol: tuplesetRelation, Type: typeName, Relation: tuplesetRelation}, + } +} diff --git a/pkg/go/validation/multi_file_validation.go b/pkg/go/validation/multi_file_validation.go index 700acdc1..9603d3b4 100644 --- a/pkg/go/validation/multi_file_validation.go +++ b/pkg/go/validation/multi_file_validation.go @@ -1,152 +1,85 @@ package validation import ( + "maps" "path/filepath" + "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// MultiFileValidator handles validation across multiple files and modules. -type MultiFileValidator struct { - model *openfgav1.AuthorizationModel - fileToModuleMap map[string]map[string]bool - moduleToFileMap map[string]map[string]bool - typeModuleMap map[string]string - conditionModuleMap map[string]string -} +// validateMultiFile reports every file that would contain more than one module +// when transformed back to DSL. Findings carry no position: they are about +// files, not lines. +func validateMultiFile(model *openfgav1.AuthorizationModel) error { + var fs []*Finding + + files := modulesByFile(model) + for _, file := range files.keys { + if modules := files.values[file]; len(modules) > 1 { + fs = append(fs, multipleModulesInSingleFile(file, modules)) + } + } -// ModuleInfo represents information about a module. -type ModuleInfo struct { - Name string - Files []string - Types []string + return joinFindings(fs...) } -// FileInfo represents information about a file. -type FileInfo struct { - Path string - Modules []string +// orderedGroups records a one-to-many mapping, keeping both the keys and each +// key's values in the order they were first added. +// +// A file's module names are joined into the message it reports, and the +// reference lists them in the order they appear in the model. Collecting them +// into a map would report the same model differently from one run to the next, +// and sorting them would report it differently from the other SDKs, so the +// shared corpus fails either way. +type orderedGroups struct { + keys []string + values map[string][]string } -func NewMultiFileValidator(model *openfgav1.AuthorizationModel) *MultiFileValidator { - validator := &MultiFileValidator{ - model: model, - fileToModuleMap: make(map[string]map[string]bool), - moduleToFileMap: make(map[string]map[string]bool), - typeModuleMap: make(map[string]string), - conditionModuleMap: make(map[string]string), +func (g *orderedGroups) add(key, value string) { + existing, seen := g.values[key] + if !seen { + g.keys = append(g.keys, key) } - validator.buildFileMappings() - return validator -} -func (mfv *MultiFileValidator) buildFileMappings() { - if mfv.model == nil { + if slices.Contains(existing, value) { return } - for _, typeDef := range mfv.model.GetTypeDefinitions() { - file := typeDef.GetMetadata().GetSourceInfo().GetFile() - module := typeDef.GetMetadata().GetModule() - if file != "" && module != "" { - mfv.addFileModuleMapping(file, module) - mfv.typeModuleMap[typeDef.GetType()] = module - } - } - for conditionName, condition := range mfv.model.GetConditions() { - file := condition.GetMetadata().GetSourceInfo().GetFile() - module := condition.GetMetadata().GetModule() - if file != "" && module != "" { - mfv.addFileModuleMapping(file, module) - mfv.conditionModuleMap[conditionName] = module - } - } -} -func (mfv *MultiFileValidator) addFileModuleMapping(file, module string) { - file = filepath.Clean(file) - if mfv.fileToModuleMap[file] == nil { - mfv.fileToModuleMap[file] = make(map[string]bool) - } - mfv.fileToModuleMap[file][module] = true - if mfv.moduleToFileMap[module] == nil { - mfv.moduleToFileMap[module] = make(map[string]bool) - } - mfv.moduleToFileMap[module][file] = true + g.values[key] = append(existing, value) } -// ValidateMultiFileConsistency validates consistency across multiple files. -func ValidateMultiFileConsistency(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validator := NewMultiFileValidator(model) - validator.validateMultipleModulesInFile(collector) -} +// modulesByFile walks the model in the order the reference walks it: every +// type, then every relation, then every condition. The passes are separate +// because a relation's module is recorded after the module of every type, not +// after its own type's. +func modulesByFile(model *openfgav1.AuthorizationModel) *orderedGroups { + files := &orderedGroups{values: make(map[string][]string)} -func (mfv *MultiFileValidator) validateMultipleModulesInFile(collector *ErrorCollector) { - for file, modules := range mfv.fileToModuleMap { - if len(modules) > 1 { - moduleNames := make([]string, 0, len(modules)) - for module := range modules { - moduleNames = append(moduleNames, module) - } - collector.RaiseMultipleModulesInSingleFile(file, moduleNames) + record := func(file, module string) { + if file != "" && module != "" { + files.add(filepath.Clean(file), module) } } -} -func (mfv *MultiFileValidator) GetModuleInfo() []ModuleInfo { - modules := make([]ModuleInfo, 0, len(mfv.moduleToFileMap)) - for moduleName, files := range mfv.moduleToFileMap { - mi := ModuleInfo{Name: moduleName, Files: make([]string, 0, len(files)), Types: make([]string, 0)} - for file := range files { - mi.Files = append(mi.Files, file) - } - for typeName, typeModule := range mfv.typeModuleMap { - if typeModule == moduleName { - mi.Types = append(mi.Types, typeName) - } - } - modules = append(modules, mi) + for _, typeDef := range model.GetTypeDefinitions() { + record(typeMeta(typeDef)) } - return modules -} -func (mfv *MultiFileValidator) GetFileInfo() []FileInfo { - files := make([]FileInfo, 0, len(mfv.fileToModuleMap)) - for filePath, modules := range mfv.fileToModuleMap { - fi := FileInfo{Path: filePath, Modules: make([]string, 0, len(modules))} - for module := range modules { - fi.Modules = append(fi.Modules, module) + for _, typeDef := range model.GetTypeDefinitions() { + // Relation names arrive in a proto map, which has no order of its own, + // so they are walked in name order. + for _, relationName := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { + record(relationMeta(typeDef, relationName)) } - files = append(files, fi) } - return files -} -func (mfv *MultiFileValidator) IsMultiModuleProject() bool { return len(mfv.moduleToFileMap) > 1 } -func (mfv *MultiFileValidator) IsMultiFileProject() bool { return len(mfv.fileToModuleMap) > 1 } -func (mfv *MultiFileValidator) GetModuleForType(typeName string) string { - return mfv.typeModuleMap[typeName] -} -func (mfv *MultiFileValidator) GetModuleForCondition(conditionName string) string { - return mfv.conditionModuleMap[conditionName] -} -func (mfv *MultiFileValidator) GetFilesForModule(moduleName string) []string { - files := make([]string, 0, len(mfv.moduleToFileMap[moduleName])) - if moduleFiles, exists := mfv.moduleToFileMap[moduleName]; exists { - for file := range moduleFiles { - files = append(files, file) - } + conditions := model.GetConditions() + for _, conditionName := range slices.Sorted(maps.Keys(conditions)) { + condition := conditions[conditionName] + record(condition.GetMetadata().GetSourceInfo().GetFile(), condition.GetMetadata().GetModule()) } + return files } -func (mfv *MultiFileValidator) GetModulesForFile(filePath string) []string { - modules := make([]string, 0, len(mfv.fileToModuleMap[filePath])) - if fileModules, exists := mfv.fileToModuleMap[filePath]; exists { - for module := range fileModules { - modules = append(modules, module) - } - } - return modules -} diff --git a/pkg/go/validation/name_validation.go b/pkg/go/validation/name_validation.go index 4b9a4150..9c60925f 100644 --- a/pkg/go/validation/name_validation.go +++ b/pkg/go/validation/name_validation.go @@ -2,244 +2,101 @@ package validation import ( "fmt" + "maps" "regexp" - "strings" + "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ValidationRegexRules contains the regex rules for validation -// These match the Rules from the JS implementation -var ValidationRegexRules = struct { - Type string - Relation string - Condition string - ID string - Object string -}{ - Type: "[^:#@\\*\\s]{1,254}", - Relation: "[^:#@\\*\\s]{1,50}", - Condition: "[^\\*\\s]{1,50}", - ID: "[^#:\\s*][a-zA-Z0-9_|*@.+]*", - Object: "[^\\s]{2,256}", -} - -// The anchored type, relation, and condition name rules are fixed, so compile -// them once. compiledNameRules caches them by their anchored rule string, which -// is also the clause reported in the error, so validateFieldValue can look up -// the compiled pattern without recompiling on every name. +// The anchored name rules, compiled once. Each rule string doubles as the +// clause quoted in the invalid-name message, so the values are corpus-pinned. var ( - typeNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Type) - relationNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Relation) - conditionNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Condition) - - compiledNameRules = map[string]*regexp.Regexp{ - typeNameRule: regexp.MustCompile(typeNameRule), - relationNameRule: regexp.MustCompile(relationNameRule), - conditionNameRule: regexp.MustCompile(conditionNameRule), - } -) + typeNameRule = fmt.Sprintf("^%s$", RuleType) + relationNameRule = fmt.Sprintf("^%s$", RuleRelation) + conditionNameRule = fmt.Sprintf("^%s$", RuleCondition) -// ValidateTypeName validates a type name with both regex and reserved keyword checking -// This enhances the basic regex validation with semantic checks -func ValidateTypeName(typeName string, collector *ErrorCollector, lineIndex *int, meta *Meta) bool { - // First check if it's a reserved keyword - if IsReservedTypeName(typeName) { - collector.RaiseReservedTypeName(typeName, lineIndex, meta) - return false - } - - // Then check regex pattern. The clause passed to the error is the full - // anchored rule, matching the reference implementation's reported rule. - if !validateFieldValue(typeNameRule, typeName) { - collector.RaiseInvalidName(typeName, typeNameRule, nil, lineIndex, meta) - return false - } - - return true -} - -// ValidateRelationName validates a relation name with both regex and reserved keyword checking -// This enhances the basic regex validation with semantic checks -func ValidateRelationName(relationName, typeName string, collector *ErrorCollector, lineIndex *int, meta *Meta) bool { - // First check if it's a reserved keyword - if IsReservedRelationName(relationName) { - collector.RaiseReservedRelationName(relationName, lineIndex, meta) - return false - } - - // Then check regex pattern. The clause passed to the error is the full - // anchored rule, matching the reference implementation's reported rule. - if !validateFieldValue(relationNameRule, relationName) { - collector.RaiseInvalidName(relationName, relationNameRule, &typeName, lineIndex, meta) - return false - } - - return true -} - -// ValidateConditionName validates a condition name with regex pattern. -func ValidateConditionName(conditionName string, collector *ErrorCollector, lineIndex *int, meta *Meta) bool { - if !validateFieldValue(conditionNameRule, conditionName) { - collector.RaiseInvalidName(conditionName, conditionNameRule, nil, lineIndex, meta) - return false - } - - return true -} + typeNameRegex = regexp.MustCompile(typeNameRule) + relationNameRegex = regexp.MustCompile(relationNameRule) + conditionNameRegex = regexp.MustCompile(conditionNameRule) +) -// validateFieldValue validates a field against a regex rule. Fixed rules are -// served from the precompiled cache; any other rule is compiled on demand. -func validateFieldValue(rule, value string) bool { - if regex, ok := compiledNameRules[rule]; ok { - return regex.MatchString(value) - } - regex, err := regexp.Compile(rule) - if err != nil { - return false - } - return regex.MatchString(value) +// reservedKeywords are the words a type or relation cannot be named. +var reservedKeywords = map[string]bool{ + "self": true, + "this": true, } -// GetTypeLineNumber finds the line number where a type is defined -// This is equivalent to the getTypeLineNumber function in JS -func GetTypeLineNumber(typeName string, lines []string, skipIndex *int) *int { - if len(lines) == 0 { - return nil - } - - for i, line := range lines { - // Skip the specified index if provided - if skipIndex != nil && i == *skipIndex { - continue - } - - // Look for "type typeName" pattern - trimmedLine := strings.TrimSpace(line) - if strings.HasPrefix(trimmedLine, "type ") { - parts := strings.Fields(trimmedLine) - if len(parts) >= 2 && parts[1] == typeName { - return &i - } - } +// validateTypeName checks one type name against the reserved keywords and the +// naming rule, returning the finding or nil. It knows nothing about the source +// text; the caller stamps position. +func validateTypeName(name string) *Finding { + switch { + case reservedKeywords[name]: + return reservedTypeName(name) + case !typeNameRegex.MatchString(name): + return invalidTypeName(name) } return nil } -// GetRelationLineNumber finds the line number where a relation is defined. -// skipIndex, when provided, is the index to begin searching from (inclusive) — -// matching the reference implementation's getRelationLineNumber, which slices -// the lines from skipIndex onward. This lets callers anchor the search to a -// specific type block so the correct occurrence is found when several types -// declare a relation of the same name. -func GetRelationLineNumber(relationName string, lines []string, skipIndex *int) *int { - if len(lines) == 0 { - return nil - } - - start := 0 - if skipIndex != nil && *skipIndex > 0 { - start = *skipIndex - } - - for i := start; i < len(lines); i++ { - // Look for "define relationName:" pattern - trimmedLine := strings.TrimSpace(lines[i]) - if strings.HasPrefix(trimmedLine, "define ") { - // Extract relation name from "define relationName:" - definePart := strings.TrimPrefix(trimmedLine, "define ") - colonIndex := strings.Index(definePart, ":") - if colonIndex > 0 { - relationInLine := strings.TrimSpace(definePart[:colonIndex]) - if relationInLine == relationName { - return &i - } - } - } +// validateRelationName checks one relation name, returning the finding or nil. +func validateRelationName(name, typeName string) *Finding { + switch { + case reservedKeywords[name]: + return reservedRelationName(name, typeName) + case !relationNameRegex.MatchString(name): + return invalidRelationName(name, typeName) } return nil } -// GetConditionLineNumber finds the line number where a condition is defined -// This is equivalent to the geConditionLineNumber function in JS -func GetConditionLineNumber(conditionName string, lines []string, skipIndex *int) *int { - if len(lines) == 0 { - return nil - } - - start := 0 - if skipIndex != nil && *skipIndex > 0 { - start = *skipIndex - } - - conditionPrefix := "condition " + conditionName - for i := start; i < len(lines); i++ { - // Match the condition declaration itself, mirroring the reference's - // `condition ` prefix check, so we don't match an unrelated line - // that merely contains the condition name as a substring. The parameter - // list's `(` must follow the name so a condition whose name is a prefix - // of another (e.g. `less` vs `less_than`) cannot match the wrong line. - trimmedLine := strings.TrimSpace(lines[i]) - if !strings.HasPrefix(trimmedLine, conditionPrefix) { - continue - } - if strings.HasPrefix(strings.TrimLeft(trimmedLine[len(conditionPrefix):], " \t"), "(") { - return &i - } +// validateConditionName checks one condition name, returning the finding or nil. +func validateConditionName(name string) *Finding { + if !conditionNameRegex.MatchString(name) { + return invalidConditionName(name) } return nil } -// ValidateNameRules validates naming rules for types and relations in a model -// This is equivalent to the populateRelations function's naming validation in JS -func ValidateNameRules(collector *ErrorCollector, typeName string, relationNames []string, - typeLineIndex *int, meta *Meta, lines []string) { - // Validate type name - ValidateTypeName(typeName, collector, typeLineIndex, meta) - - // Validate relation names - for _, relationName := range relationNames { - relationLineIndex := GetRelationLineNumber(relationName, lines, nil) - ValidateRelationName(relationName, typeName, collector, relationLineIndex, meta) - } -} - -// ValidateNames checks every type, relation, and condition name in the model +// validateNames checks every type, relation, and condition name in the model // against the reserved-keyword and naming-rule constraints. It mirrors the name // validation performed in the JS reference implementation's populateRelations. -func ValidateNames(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } +func validateNames(model *openfgav1.AuthorizationModel, src source) error { + var fs []*Finding for _, typeDef := range model.GetTypeDefinitions() { typeName := typeDef.GetType() if typeName == "" { continue } - meta := &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - ValidateTypeName(typeName, collector, typeLineIndex, meta) + file := typeDef.GetMetadata().GetSourceInfo().GetFile() + module := typeDef.GetMetadata().GetModule() + + typeLine := src.typeLine(typeName) + fs = append(fs, validateTypeName(typeName).at(src, typeLine).in(file, module)) - for relationName := range typeDef.GetRelations() { - relationLineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - ValidateRelationName(relationName, typeName, collector, relationLineIndex, meta) + // Relations reach us in a proto map, which has no order, so they are + // walked in name order here and in every other phase to report the same + // model's findings in the same order from run to run. + for _, relationName := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { + relationLine := src.relationLine(relationName, typeLine) + fs = append(fs, validateRelationName(relationName, typeName).at(src, relationLine).in(file, module)) } } - for conditionName, condition := range model.GetConditions() { - conditionLineIndex := GetConditionLineNumber(conditionName, lines, nil) - meta := &Meta{ - File: condition.GetMetadata().GetSourceInfo().GetFile(), - Module: condition.GetMetadata().GetModule(), - } - ValidateConditionName(conditionName, collector, conditionLineIndex, meta) + conditions := model.GetConditions() + for _, conditionName := range slices.Sorted(maps.Keys(conditions)) { + condition := conditions[conditionName] + file := condition.GetMetadata().GetSourceInfo().GetFile() + module := condition.GetMetadata().GetModule() + + fs = append(fs, validateConditionName(conditionName).at(src, src.conditionLine(conditionName)).in(file, module)) } + + return joinFindings(fs...) } diff --git a/pkg/go/validation/name_validation_test.go b/pkg/go/validation/name_validation_test.go index 61520757..024918ad 100644 --- a/pkg/go/validation/name_validation_test.go +++ b/pkg/go/validation/name_validation_test.go @@ -4,628 +4,119 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestValidateTypeName(t *testing.T) { + t.Parallel() -func TestValidationRegexRules(t *testing.T) { - // Test that regex rules match the JS implementation - assert.Equal(t, "[^:#@\\*\\s]{1,254}", ValidationRegexRules.Type) - assert.Equal(t, "[^:#@\\*\\s]{1,50}", ValidationRegexRules.Relation) - assert.Equal(t, "[^\\*\\s]{1,50}", ValidationRegexRules.Condition) - assert.Equal(t, "[^#:\\s*][a-zA-Z0-9_|*@.+]*", ValidationRegexRules.ID) - assert.Equal(t, "[^\\s]{2,256}", ValidationRegexRules.Object) -} + t.Run("valid name yields nothing", func(t *testing.T) { + t.Parallel() + assert.Nil(t, validateTypeName("document")) + }) -func TestValidateFieldValue(t *testing.T) { - tests := []struct { - name string - rule string - value string - expected bool - }{ - { - name: "valid type name", - rule: "^[a-zA-Z]+$", - value: "document", - expected: true, - }, - { - name: "invalid type name with numbers", - rule: "^[a-zA-Z]+$", - value: "document123", - expected: false, - }, - { - name: "valid relation name", - rule: "^[a-zA-Z_]+$", - value: "can_view", - expected: true, - }, - { - name: "empty string with appropriate rule", - rule: "^$", - value: "", - expected: true, - }, - { - name: "invalid regex pattern", - rule: "[unclosed", - value: "test", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validateFieldValue(tt.rule, tt.value) - assert.Equal(t, tt.expected, result) - }) - } -} + t.Run("reserved keywords", func(t *testing.T) { + t.Parallel() -func TestValidateTypeName(t *testing.T) { - tests := []struct { - name string - typeName string - expectedValid bool - expectedErrorType ValidationErrorType - expectedErrorCount int - }{ - { - name: "valid type name", - typeName: "document", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "reserved keyword self", - typeName: "self", - expectedValid: false, - expectedErrorType: ReservedTypeKeywords, - expectedErrorCount: 1, - }, - { - name: "reserved keyword this", - typeName: "this", - expectedValid: false, - expectedErrorType: ReservedTypeKeywords, - expectedErrorCount: 1, - }, - { - name: "valid type name with underscore", - typeName: "user_group", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "type name with invalid characters", - typeName: "document:invalid", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "type name with space", - typeName: "document name", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "type name with hash", - typeName: "document#tag", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} - - result := ValidateTypeName(tt.typeName, collector, &lineIndex, meta) - - assert.Equal(t, tt.expectedValid, result) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, tt.expectedErrorType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.typeName, errors[0].Metadata.Symbol) - } - }) - } + for _, reserved := range []string{"self", "this"} { + finding := validateTypeName(reserved) + + require.NotNil(t, finding) + assert.Equal(t, ReservedTypeKeywords, finding.Metadata.Kind) + assert.Equal(t, "a type cannot be named 'self' or 'this'.", finding.Message) + assert.Equal(t, reserved, finding.Metadata.Symbol) + assert.Equal(t, reserved, finding.Metadata.Type) + } + }) + + t.Run("rule violation quotes the anchored rule", func(t *testing.T) { + t.Parallel() + + finding := validateTypeName("doc:ument") + + require.NotNil(t, finding) + assert.Equal(t, InvalidName, finding.Metadata.Kind) + assert.Equal(t, "type 'doc:ument' does not match naming rule: '^[^:#@\\*\\s]{1,254}$'.", finding.Message) + }) } func TestValidateRelationName(t *testing.T) { - tests := []struct { - name string - relationName string - typeName string - expectedValid bool - expectedErrorType ValidationErrorType - expectedErrorCount int - }{ - { - name: "valid relation name", - relationName: "viewer", - typeName: "document", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "reserved keyword self", - relationName: "self", - typeName: "document", - expectedValid: false, - expectedErrorType: ReservedRelationKeywords, - expectedErrorCount: 1, - }, - { - name: "reserved keyword this", - relationName: "this", - typeName: "document", - expectedValid: false, - expectedErrorType: ReservedRelationKeywords, - expectedErrorCount: 1, - }, - { - name: "valid relation name with underscore", - relationName: "can_view", - typeName: "document", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "relation name with invalid characters", - relationName: "viewer:invalid", - typeName: "document", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "relation name with space", - relationName: "can view", - typeName: "document", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "relation name with hash", - relationName: "view#tag", - typeName: "document", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 8 - meta := &Meta{File: "test.fga", Module: "test"} - - result := ValidateRelationName(tt.relationName, tt.typeName, collector, &lineIndex, meta) - - assert.Equal(t, tt.expectedValid, result) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, tt.expectedErrorType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.relationName, errors[0].Metadata.Symbol) - - // Check that error message includes type name for relation errors - if tt.expectedErrorType == InvalidName { - assert.Contains(t, errors[0].Message, tt.typeName) - } - } - }) - } + t.Parallel() + + t.Run("valid name yields nothing", func(t *testing.T) { + t.Parallel() + assert.Nil(t, validateRelationName("viewer", "document")) + }) + + t.Run("reserved keyword", func(t *testing.T) { + t.Parallel() + + finding := validateRelationName("self", "document") + + require.NotNil(t, finding) + assert.Equal(t, ReservedRelationKeywords, finding.Metadata.Kind) + assert.Equal(t, "a relation cannot be named 'self' or 'this'.", finding.Message) + assert.Equal(t, "document", finding.Metadata.Type) + assert.Equal(t, "self", finding.Metadata.Relation) + }) + + t.Run("rule violation names the relation and its type", func(t *testing.T) { + t.Parallel() + + finding := validateRelationName("view#er", "document") + + require.NotNil(t, finding) + assert.Equal(t, InvalidName, finding.Metadata.Kind) + assert.Equal(t, + "relation 'view#er' of type 'document' does not match naming rule: '^[^:#@\\*\\s]{1,50}$'.", + finding.Message) + }) } func TestValidateConditionName(t *testing.T) { - tests := []struct { - name string - conditionName string - expectedValid bool - expectedErrorCount int - }{ - { - name: "valid condition name", - conditionName: "is_owner", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "valid condition name with numbers", - conditionName: "condition123", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "condition name with space", - conditionName: "is owner", - expectedValid: false, - expectedErrorCount: 1, - }, - { - name: "condition name with asterisk", - conditionName: "condition*", - expectedValid: false, - expectedErrorCount: 1, - }, - { - name: "empty condition name", - conditionName: "", - expectedValid: false, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 10 - meta := &Meta{File: "test.fga", Module: "test"} - - result := ValidateConditionName(tt.conditionName, collector, &lineIndex, meta) - - assert.Equal(t, tt.expectedValid, result) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, InvalidName, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.conditionName, errors[0].Metadata.Symbol) - } - }) - } -} + t.Parallel() -func TestGetTypeLineNumber(t *testing.T) { - tests := []struct { - name string - typeName string - lines []string - skipIndex *int - expected *int - }{ - { - name: "finds type on line 0", - typeName: "document", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - }, - expected: ptrInt(0), - }, - { - name: "finds type on line 2", - typeName: "user", - lines: []string{ - "model", - " schema 1.1", - "type user", - "type document", - }, - expected: ptrInt(2), - }, - { - name: "type not found", - typeName: "nonexistent", - lines: []string{ - "type document", - "type user", - }, - expected: nil, - }, - { - name: "skips specified index", - typeName: "document", - lines: []string{ - "type document", - " relations", - "type document", - }, - skipIndex: ptrInt(0), - expected: ptrInt(2), - }, - { - name: "empty lines", - typeName: "document", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - typeName: "document", - lines: nil, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetTypeLineNumber(tt.typeName, tt.lines, tt.skipIndex) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } -} + assert.Nil(t, validateConditionName("is_valid")) -func TestGetRelationLineNumber(t *testing.T) { - tests := []struct { - name string - relationName string - lines []string - skipIndex *int - expected *int - }{ - { - name: "finds relation on line 2", - relationName: "viewer", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - " define admin: [user]", - }, - expected: ptrInt(2), - }, - { - name: "finds relation with complex definition", - relationName: "can_view", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - " define can_view: viewer or admin", - }, - expected: ptrInt(3), - }, - { - name: "relation not found", - relationName: "nonexistent", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - }, - expected: nil, - }, - { - name: "searches from skipIndex onward", - relationName: "viewer", - lines: []string{ - " define viewer: [user]", - " relations", - " define viewer: [group]", - }, - // skipIndex is a start offset: searching from index 1 finds the - // second occurrence at index 2. - skipIndex: ptrInt(1), - expected: ptrInt(2), - }, - { - name: "empty lines", - relationName: "viewer", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - relationName: "viewer", - lines: nil, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetRelationLineNumber(tt.relationName, tt.lines, tt.skipIndex) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } -} + finding := validateConditionName("has space") -func TestGetConditionLineNumber(t *testing.T) { - tests := []struct { - name string - conditionName string - lines []string - skipIndex *int - expected *int - }{ - { - name: "finds condition declaration", - conditionName: "is_owner", - lines: []string{ - "type document", - " relations", - " define viewer: [user with is_owner]", - "condition is_owner(x: int) {", - }, - expected: ptrInt(3), - }, - { - name: "condition not found", - conditionName: "nonexistent", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - }, - expected: nil, - }, - { - name: "searches from skipIndex onward", - conditionName: "is_owner", - lines: []string{ - "condition is_owner(x: int) {", - " relations", - "condition is_owner(y: int) {", - }, - // skipIndex is a start offset: from index 1 the next declaration is at 2. - skipIndex: ptrInt(1), - expected: ptrInt(2), - }, - { - name: "empty lines", - conditionName: "is_owner", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - conditionName: "is_owner", - lines: nil, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetConditionLineNumber(tt.conditionName, tt.lines, tt.skipIndex) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } + require.NotNil(t, finding) + assert.Equal(t, InvalidName, finding.Metadata.Kind) + assert.Equal(t, "condition 'has space' does not match naming rule: '^[^\\*\\s]{1,50}$'.", finding.Message) + assert.Equal(t, "has space", finding.Metadata.Condition) } -func TestValidateNameRules(t *testing.T) { - tests := []struct { - name string - typeName string - relationNames []string - lines []string - expectedErrorCount int - }{ - { - name: "valid type and relations", - typeName: "document", - relationNames: []string{"viewer", "admin", "owner"}, - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - " define admin: [user] ", - " define owner: [user]", - }, - expectedErrorCount: 0, - }, - { - name: "reserved type name", - typeName: "self", - relationNames: []string{"viewer"}, - lines: []string{ - "type self", - " relations", - " define viewer: [user]", - }, - expectedErrorCount: 1, - }, - { - name: "reserved relation name", - typeName: "document", - relationNames: []string{"this", "viewer"}, - lines: []string{ - "type document", - " relations", - " define this: [user]", - " define viewer: [user]", - }, - expectedErrorCount: 1, - }, - { - name: "multiple validation errors", - typeName: "self", - relationNames: []string{"this", "viewer"}, - lines: []string{ - "type self", - " relations", - " define this: [user]", - " define viewer: [user]", - }, - expectedErrorCount: 2, - }, - { - name: "invalid type name characters", - typeName: "document:invalid", - relationNames: []string{"viewer"}, - lines: []string{ - "type document:invalid", - " relations", - " define viewer: [user]", - }, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) - typeLineIndex := GetTypeLineNumber(tt.typeName, tt.lines, nil) - meta := &Meta{File: "test.fga", Module: "test"} - - ValidateNameRules(collector, tt.typeName, tt.relationNames, typeLineIndex, meta, tt.lines) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - }) - } -} +func TestValidateNames(t *testing.T) { + t.Parallel() -// TestNameValidationIntegration tests name validation with proto types. -func TestNameValidationIntegration(t *testing.T) { - t.Run("Valid Names", func(t *testing.T) { - validNames := []string{"document", "user", "group", "viewer", "editor", "admin"} + t.Run("positions resolve to the declaring lines", func(t *testing.T) { + t.Parallel() - for _, name := range validNames { - collector := NewErrorCollector(nil) - typeValid := ValidateTypeName(name, collector, nil, nil) - assert.True(t, typeValid, "Expected %s to be valid type name", name) + // The parser would reject these names; build the model directly, as the + // phase sees it. + dsl := "model\n schema 1.1\ntype self\n relations\n define this: [self]" + model := modelWithRelations(t, "self", "this") - collector = NewErrorCollector(nil) - relationValid := ValidateRelationName(name, "parent_type", collector, nil, nil) - assert.True(t, relationValid, "Expected %s to be valid relation name", name) - } + findings := ExtractAllAs[*Finding](validateNames(model, newSource(dsl))) + + require.Len(t, findings, 2) + + assert.Equal(t, ReservedTypeKeywords, findings[0].Metadata.Kind) + assert.Equal(t, &Range{Start: 2, End: 2}, findings[0].Line) + assert.Equal(t, &Range{Start: 5, End: 9}, findings[0].Column) + + assert.Equal(t, ReservedRelationKeywords, findings[1].Metadata.Kind) + assert.Equal(t, &Range{Start: 4, End: 4}, findings[1].Line) + assert.Equal(t, &Range{Start: 11, End: 15}, findings[1].Column) }) - t.Run("Reserved Keywords", func(t *testing.T) { - reservedKeywords := []string{"this", "self"} + t.Run("no source text means no positions", func(t *testing.T) { + t.Parallel() - for _, keyword := range reservedKeywords { - collector := NewErrorCollector(nil) - typeValid := ValidateTypeName(keyword, collector, nil, nil) - assert.False(t, typeValid, "Expected %s to be invalid type name", keyword) + findings := ExtractAllAs[*Finding](validateNames(modelWithRelations(t, "self", "viewer"), source{})) - collector = NewErrorCollector(nil) - relationValid := ValidateRelationName(keyword, "parent_type", collector, nil, nil) - assert.False(t, relationValid, "Expected %s to be invalid relation name", keyword) - } + require.Len(t, findings, 1) + assert.Nil(t, findings[0].Line) + assert.Nil(t, findings[0].Column) }) } diff --git a/pkg/go/validation/schema_validation.go b/pkg/go/validation/schema_validation.go index 9e2995fa..1a1730cb 100644 --- a/pkg/go/validation/schema_validation.go +++ b/pkg/go/validation/schema_validation.go @@ -1,86 +1,25 @@ package validation import ( - "regexp" - "strings" - openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -const ( - SchemaVersion11 = "1.1" - SchemaVersion12 = "1.2" -) - -var SupportedSchemaVersions = map[string]bool{ - SchemaVersion11: true, - SchemaVersion12: true, -} - -// multiSpaceRegex collapses runs of whitespace when normalizing a DSL line for -// schema-version matching. Hoisted so it is compiled once, not per line. -var multiSpaceRegex = regexp.MustCompile(`\s{2,}`) +// validateSchemaVersion reports a missing, retired, or never-valid schema +// version. A model with no version at all is reported at line zero, matching +// the reference. +func validateSchemaVersion(model *openfgav1.AuthorizationModel, src source) error { + version := model.GetSchemaVersion() -func IsValidSchemaVersion(version string) bool { - return SupportedSchemaVersions[version] -} - -func GetSchemaLineNumber(schemaVersion string, lines []string) *int { - if len(lines) == 0 { + switch version { + case "": + return schemaVersionRequired().at(src, 0) + case "1.1", "1.2": return nil - } - pattern := `^\s*schema\s+` + regexp.QuoteMeta(schemaVersion) + `\s*$` - regex := regexp.MustCompile(pattern) - for i, line := range lines { - normalizedLine := strings.TrimSpace(line) - normalizedLine = multiSpaceRegex.ReplaceAllString(normalizedLine, " ") - if regex.MatchString(normalizedLine) { - return &i - } - } - return nil -} - -// ValidateSchemaVersion validates the schema version of an authorization model. -func ValidateSchemaVersion(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - schemaVersion := model.GetSchemaVersion() - if schemaVersion == "" { - lineIndex := 0 - collector.RaiseSchemaVersionRequired("", &lineIndex) - return - } - switch schemaVersion { - case SchemaVersion11, SchemaVersion12: - // Supported — nothing to report. case "1.0": // Recognized but retired. - collector.RaiseSchemaVersionUnsupported(schemaVersion, GetSchemaLineNumber(schemaVersion, lines)) + return schemaVersionUnsupported(version).at(src, src.schemaLine(version)) default: // Never a valid schema version. - collector.RaiseInvalidSchemaVersion(schemaVersion, GetSchemaLineNumber(schemaVersion, lines)) + return invalidSchemaVersion(version).at(src, src.schemaLine(version)) } } - -// ValidateMultipleModulesInFile checks for multiple modules defined in single files. -func ValidateMultipleModulesInFile(collector *ErrorCollector, fileToModuleMap map[string]map[string]bool) { - for file, moduleMap := range fileToModuleMap { - if len(moduleMap) <= 1 { - continue - } - modules := make([]string, 0, len(moduleMap)) - for module := range moduleMap { - modules = append(modules, module) - } - collector.RaiseMultipleModulesInSingleFile(file, modules) - } -} - -// ValidateBasicModelStructure performs basic model structure validation. -func ValidateBasicModelStructure(collector *ErrorCollector, model *openfgav1.AuthorizationModel, - fileToModuleMap map[string]map[string]bool, lines []string) { - ValidateSchemaVersion(collector, model, lines) - ValidateMultipleModulesInFile(collector, fileToModuleMap) -} diff --git a/pkg/go/validation/schema_validation_test.go b/pkg/go/validation/schema_validation_test.go index 53c494a5..a538f174 100644 --- a/pkg/go/validation/schema_validation_test.go +++ b/pkg/go/validation/schema_validation_test.go @@ -5,394 +5,64 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) - -func TestIsValidSchemaVersion(t *testing.T) { - tests := []struct { - name string - version string - expected bool - }{ - { - name: "version 1.1 is valid", - version: "1.1", - expected: true, - }, - { - name: "version 1.2 is valid", - version: "1.2", - expected: true, - }, - { - name: "version 1.0 is invalid", - version: "1.0", - expected: false, - }, - { - name: "version 2.0 is invalid", - version: "2.0", - expected: false, - }, - { - name: "empty string is invalid", - version: "", - expected: false, - }, - { - name: "random string is invalid", - version: "invalid", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsValidSchemaVersion(tt.version) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestGetSchemaLineNumber(t *testing.T) { - tests := []struct { - name string - schemaVersion string - lines []string - expected *int - }{ - { - name: "finds schema version on line 1", - schemaVersion: "1.1", - lines: []string{ - "model", - " schema 1.1", - "type document", - }, - expected: ptrInt(1), - }, - { - name: "finds schema version with extra whitespace", - schemaVersion: "1.2", - lines: []string{ - "model", - " schema 1.2 ", - "type document", - }, - expected: ptrInt(1), - }, - { - name: "schema version not found", - schemaVersion: "1.1", - lines: []string{ - "model", - "type document", - }, - expected: nil, - }, - { - name: "empty lines", - schemaVersion: "1.1", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - schemaVersion: "1.1", - lines: nil, - expected: nil, - }, - { - name: "finds first occurrence when multiple matches", - schemaVersion: "1.1", - lines: []string{ - "model", - " schema 1.1", - "# comment about schema 1.1", - "type document", - }, - expected: ptrInt(1), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetSchemaLineNumber(tt.schemaVersion, tt.lines) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } -} - func TestValidateSchemaVersion(t *testing.T) { - tests := []struct { - name string - model *openfgav1.AuthorizationModel - lines []string - expectedErrorCount int - expectedErrorType ValidationErrorType - expectedErrorSymbol string - }{ - { - name: "nil model", - model: nil, - expectedErrorCount: 0, - }, - { - name: "missing schema version", - model: &openfgav1.AuthorizationModel{}, - expectedErrorCount: 1, - expectedErrorType: SchemaVersionRequired, - expectedErrorSymbol: "", - }, - { - name: "empty schema version", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "", - }, - expectedErrorCount: 1, - expectedErrorType: SchemaVersionRequired, - expectedErrorSymbol: "", - }, - { - name: "valid schema version 1.1", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - expectedErrorCount: 0, - }, - { - name: "valid schema version 1.2", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.2", - }, - expectedErrorCount: 0, - }, - { - name: "invalid schema version", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - }, - lines: []string{ - "model", - " schema 2.0", - "type document", - }, - expectedErrorCount: 1, - expectedErrorType: InvalidSchema, - expectedErrorSymbol: "2.0", - }, - { - name: "retired schema version 1.0", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.0", - }, - lines: []string{ - "model", - " schema 1.0", - "type document", - }, - expectedErrorCount: 1, - expectedErrorType: SchemaVersionUnsupported, - expectedErrorSymbol: "1.0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) - - ValidateSchemaVersion(collector, tt.model, tt.lines) + t.Parallel() - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, tt.expectedErrorType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.expectedErrorSymbol, errors[0].Metadata.Symbol) - } - }) + model := func(version string) *openfgav1.AuthorizationModel { + return &openfgav1.AuthorizationModel{SchemaVersion: version} } -} -func TestValidateMultipleModulesInFile(t *testing.T) { - tests := []struct { - name string - fileToModuleMap map[string]map[string]bool - expectedErrorCount int - expectedFile string - expectedModules []string - }{ - { - name: "no files", - fileToModuleMap: map[string]map[string]bool{}, - expectedErrorCount: 0, - }, - { - name: "single module per file", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, - "file2.fga": {"module2": true}, - }, - expectedErrorCount: 0, - }, - { - name: "multiple modules in single file", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - "module3": true, - }, - }, - expectedErrorCount: 1, - expectedFile: "file1.fga", - expectedModules: []string{"module1", "module2", "module3"}, - }, - { - name: "mixed: some files with single, some with multiple modules", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, - "file2.fga": { - "module2": true, - "module3": true, - }, - "file3.fga": {"module4": true}, - }, - expectedErrorCount: 1, - expectedFile: "file2.fga", - expectedModules: []string{"module2", "module3"}, - }, - } + t.Run("supported versions yield nothing", func(t *testing.T) { + t.Parallel() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + assert.Empty(t, ExtractAllAs[*Finding](validateSchemaVersion(model("1.1"), source{}))) + assert.Empty(t, ExtractAllAs[*Finding](validateSchemaVersion(model("1.2"), source{}))) + }) - ValidateMultipleModulesInFile(collector, tt.fileToModuleMap) + t.Run("missing version is required at line zero", func(t *testing.T) { + t.Parallel() - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model(""), newSource("model\ntype user"))) - if tt.expectedErrorCount > 0 { - assert.Equal(t, MultipleModulesInFile, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.expectedFile, errors[0].Metadata.Symbol) + require.Len(t, findings, 1) + assert.Equal(t, "schema version required", findings[0].Message) + assert.Equal(t, SchemaVersionRequired, findings[0].Metadata.Kind) + assert.Equal(t, &Range{Start: 0, End: 0}, findings[0].Line) + }) - // Check that all expected modules are mentioned in the error message - for _, module := range tt.expectedModules { - assert.Contains(t, errors[0].Message, module) - } - } - }) - } -} + t.Run("1.0 is recognized but retired", func(t *testing.T) { + t.Parallel() -func TestValidateBasicModelStructure(t *testing.T) { - tests := []struct { - name string - model *openfgav1.AuthorizationModel - fileToModuleMap map[string]map[string]bool - lines []string - expectedErrorCount int - }{ - { - name: "valid model structure", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, - }, - expectedErrorCount: 0, - }, - { - name: "missing schema version", - model: &openfgav1.AuthorizationModel{}, - fileToModuleMap: map[string]map[string]bool{}, - expectedErrorCount: 1, - }, - { - name: "invalid schema version", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - }, - fileToModuleMap: map[string]map[string]bool{}, - expectedErrorCount: 1, - }, - { - name: "multiple modules in file", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - }, - }, - expectedErrorCount: 1, - }, - { - name: "multiple errors", - model: &openfgav1.AuthorizationModel{}, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - }, - }, - expectedErrorCount: 2, - }, - } + findings := ExtractAllAs[*Finding](validateSchemaVersion(model("1.0"), newSource("model\n schema 1.0\ntype user"))) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) + require.Len(t, findings, 1) + assert.Equal(t, "schema version no longer supported", findings[0].Message) + assert.Equal(t, SchemaVersionUnsupported, findings[0].Metadata.Kind) + assert.Equal(t, &Range{Start: 1, End: 1}, findings[0].Line) + assert.Equal(t, &Range{Start: 9, End: 12}, findings[0].Column) + }) - ValidateBasicModelStructure(collector, tt.model, tt.fileToModuleMap, tt.lines) + t.Run("anything else was never valid", func(t *testing.T) { + t.Parallel() - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - }) - } -} - -func TestSupportedSchemaVersions(t *testing.T) { - // Test that the supported schema versions map is properly initialized - assert.NotNil(t, SupportedSchemaVersions) - assert.True(t, SupportedSchemaVersions[SchemaVersion11]) - assert.True(t, SupportedSchemaVersions[SchemaVersion12]) - assert.False(t, SupportedSchemaVersions["1.0"]) - assert.False(t, SupportedSchemaVersions["2.0"]) -} + findings := ExtractAllAs[*Finding](validateSchemaVersion(model("1.3"), newSource("model\n schema 1.3\ntype user"))) -func TestSchemaVersionConstants(t *testing.T) { - // Test that schema version constants are defined correctly - assert.Equal(t, "1.1", SchemaVersion11) - assert.Equal(t, "1.2", SchemaVersion12) -} + require.Len(t, findings, 1) + assert.Equal(t, "invalid schema 1.3", findings[0].Message) + assert.Equal(t, InvalidSchema, findings[0].Metadata.Kind) + assert.Equal(t, "1.3", findings[0].Metadata.Symbol) + }) -func TestSchemaVersionValidation(t *testing.T) { - collector := NewErrorCollector(nil) + t.Run("no source text means no position", func(t *testing.T) { + t.Parallel() - // Test valid schema version - validModel := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - } - ValidateSchemaVersion(collector, validModel, nil) - assert.Empty(t, collector.GetErrors()) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model("1.3"), source{})) - // Test invalid schema version - collector = NewErrorCollector(nil) - invalidModel := &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - } - ValidateSchemaVersion(collector, invalidModel, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, InvalidSchema, errors[0].Metadata.ErrorType) + require.Len(t, findings, 1) + assert.Nil(t, findings[0].Line) + assert.Nil(t, findings[0].Column) + }) } diff --git a/pkg/go/validation/semantic_validation.go b/pkg/go/validation/semantic_validation.go index 273a2d44..0708ba01 100644 --- a/pkg/go/validation/semantic_validation.go +++ b/pkg/go/validation/semantic_validation.go @@ -1,261 +1,204 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// SemanticValidator handles semantic validation of authorization models. -type SemanticValidator struct { - model *openfgav1.AuthorizationModel - typeMap map[string]*openfgav1.TypeDefinition - relationMap map[string]map[string]*openfgav1.Userset -} - -func NewSemanticValidator(model *openfgav1.AuthorizationModel) *SemanticValidator { - validator := &SemanticValidator{ - model: model, - typeMap: make(map[string]*openfgav1.TypeDefinition, len(model.GetTypeDefinitions())), - relationMap: make(map[string]map[string]*openfgav1.Userset, len(model.GetTypeDefinitions())), - } - validator.buildMaps() - return validator -} - -func (sv *SemanticValidator) buildMaps() { - if sv.model == nil { - return - } - for _, typeDef := range sv.model.GetTypeDefinitions() { - sv.typeMap[typeDef.GetType()] = typeDef - if relations := typeDef.GetRelations(); len(relations) > 0 { - sv.relationMap[typeDef.GetType()] = make(map[string]*openfgav1.Userset, len(relations)) - for relationName, userset := range relations { - sv.relationMap[typeDef.GetType()][relationName] = userset - } - } - } -} - -func (sv *SemanticValidator) RelationDefined(typeName, relationName string) bool { - if relations, exists := sv.relationMap[typeName]; exists { - _, relationExists := relations[relationName] - return relationExists - } - return false -} - -func (sv *SemanticValidator) TypeDefined(typeName string) bool { - _, exists := sv.typeMap[typeName] - return exists -} +// validateRelationReferences checks that every type and relation a relation +// refers to — in its type restrictions and in its rewrites — exists in the +// model. +func validateRelationReferences(idx *index, src source) error { + var fs []*Finding -func (sv *SemanticValidator) GetTypeDefinition(typeName string) *openfgav1.TypeDefinition { - return sv.typeMap[typeName] -} - -func (sv *SemanticValidator) GetRelationUserset(typeName, relationName string) *openfgav1.Userset { - if relations, exists := sv.relationMap[typeName]; exists { - return relations[relationName] - } - return nil -} - -// GetRelationNames returns the names of every relation defined on a type. -func (sv *SemanticValidator) GetRelationNames(typeName string) []string { - relations := sv.relationMap[typeName] - names := make([]string, 0, len(relations)) - for name := range relations { - names = append(names, name) - } - return names -} - -// GetDirectlyAssignableTypes returns the type restrictions a relation is -// directly assignable to, but only when that relation is a single direct -// assignment (i.e. `define r: [a, b]` rather than a rewrite). The bool reports -// whether the relation is such a single direct assignment. This mirrors the -// reference implementation's allowableTypes helper used for tuple-to-userset -// validation. -func (sv *SemanticValidator) GetDirectlyAssignableTypes(typeName, relationName string) ([]*openfgav1.RelationReference, bool) { - userset := sv.GetRelationUserset(typeName, relationName) - if userset == nil { - return nil, false - } - if _, ok := userset.GetUserset().(*openfgav1.Userset_This); !ok { - return nil, false - } - typeDef := sv.typeMap[typeName] - if typeDef == nil { - return nil, false - } - relMeta := typeDef.GetMetadata().GetRelations()[relationName] - return relMeta.GetDirectlyRelatedUserTypes(), true -} - -// ValidateRelationReferences validates that all relation references in the model are valid. -func ValidateRelationReferences(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateRelationReferences(collector, NewSemanticValidator(model), lines) -} - -func validateRelationReferences(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - for _, typeDef := range model.GetTypeDefinitions() { + for _, typeDef := range idx.model.GetTypeDefinitions() { typeName := typeDef.GetType() - // When a type is declared more than once, the relation maps are built - // last-wins (matching the reference's typeMap). Only the winning - // definition is validated; a shadowed duplicate's relations are resolved - // against the winning type by the reference, so validating the winning - // definition alone avoids spurious reference errors. The duplicate-type - // error itself is reported by duplicate detection. - if winning := validator.GetTypeDefinition(typeName); winning != nil && winning != typeDef { + + // When a type is declared more than once only the winning (last) + // definition is validated: the reference resolves a shadowed duplicate's + // relations against the winning type, so validating the winner alone + // avoids spurious reference errors. The duplicate itself is reported by + // the duplicates phase. + if winning := idx.typeDef(typeName); winning != nil && winning != typeDef { continue } // Anchor relation line lookups to the type's declaration so the correct - // `define` occurrence is found when several types share a relation name. - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) + // `define` occurrence is found when several types declare a relation of + // the same name. + typeLine := src.typeLine(typeName) if meta := typeDef.GetMetadata(); meta != nil { - for relationName, relationMetadata := range meta.GetRelations() { - validateTypeRestrictions(collector, validator, typeName, relationName, relationMetadata, typeLineIndex, lines) + relationsMetadata := meta.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + fs = append(fs, validateTypeRestrictions(idx, src, typeDef, relationName, + relationsMetadata[relationName], typeLine)...) } } - for relationName, userset := range typeDef.GetRelations() { - validateUsersetReferences(collector, validator, typeName, relationName, userset, typeLineIndex, lines) + + relations := typeDef.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + fs = append(fs, validateUsersetReferences(idx, src, typeDef, relationName, + relations[relationName], typeLine)...) } } + + return joinFindings(fs...) } -func validateTypeRestrictions(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, relationMetadata *openfgav1.RelationMetadata, typeLineIndex *int, lines []string) { +// validateTypeRestrictions checks a relation's directly-related user types: +// each restriction must name a defined type, and a `type#relation` restriction +// a relation defined on that type. +func validateTypeRestrictions(idx *index, src source, typeDef *openfgav1.TypeDefinition, + relationName string, relationMetadata *openfgav1.RelationMetadata, typeLine int) []*Finding { if relationMetadata == nil { - return + return nil } - meta := &Meta{ - File: relationMetadata.GetSourceInfo().GetFile(), - Module: relationMetadata.GetModule(), - } - for _, typeRestriction := range relationMetadata.GetDirectlyRelatedUserTypes() { - restrictedType := typeRestriction.GetType() + + var fs []*Finding + + typeName := typeDef.GetType() + file := relationMetadata.GetSourceInfo().GetFile() + module := relationMetadata.GetModule() + + for _, restriction := range relationMetadata.GetDirectlyRelatedUserTypes() { + restrictedType := restriction.GetType() if restrictedType == "" { continue } + // A directly-related type that doesn't exist: `X` is not a valid type. - if !validator.TypeDefined(restrictedType) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseInvalidType(restrictedType, typeName, relationName, meta, lineIndex) + if !idx.typeDefined(restrictedType) { + line := src.relationLine(relationName, typeLine) + fs = append(fs, invalidType(restrictedType).atRestriction(src, line).in(file, module)) + continue } - // A type#relation restriction whose relation doesn't exist on that type: - // `rel` is not a valid relation for `X`. - if rel := typeRestriction.GetRelation(); rel != "" { - if !validator.RelationDefined(restrictedType, rel) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - symbol := restrictedType + "#" + rel - collector.RaiseInvalidTypeRelation(symbol, restrictedType, relationName, rel, restrictedType, lineIndex, meta) - } + + // A type#relation restriction whose relation doesn't exist on that + // type: `rel` is not a valid relation for `X`. + if rel := restriction.GetRelation(); rel != "" && !idx.relationDefined(restrictedType, rel) { + line := src.relationLine(relationName, typeLine) + fs = append(fs, invalidTypeRelation(restrictedType+"#"+rel, restrictedType, relationName, rel, typeName). + at(src, line).in(file, module)) } } + + return fs } -func validateUsersetReferences(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, userset *openfgav1.Userset, typeLineIndex *int, lines []string) { +// validateUsersetReferences checks the relations a rewrite names: a computed +// userset must exist on the type, and a tuple-to-userset must satisfy +// validateTupleToUsersetReferences. Union, intersection and difference are +// walked into. +func validateUsersetReferences(idx *index, src source, typeDef *openfgav1.TypeDefinition, + relationName string, userset *openfgav1.Userset, typeLine int) []*Finding { if userset == nil { - return + return nil } - var file, module string - if typeDef := validator.GetTypeDefinition(typeName); typeDef != nil { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - module = typeDef.GetMetadata().GetModule() - } - meta := &Meta{File: file, Module: module} - if cu := userset.GetComputedUserset(); cu != nil { + var fs []*Finding + + typeName := typeDef.GetType() + file, module := typeMeta(typeDef) + + if computed := userset.GetComputedUserset(); computed != nil { // `define a: b` where b is not a relation on this type. - if targetRelation := cu.GetRelation(); targetRelation != "" { - if !validator.RelationDefined(typeName, targetRelation) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - validRelations := validator.GetRelationNames(typeName) - collector.RaiseInvalidRelationError(targetRelation, typeName, relationName, validRelations, lineIndex, meta) - } + if target := computed.GetRelation(); target != "" && !idx.relationDefined(typeName, target) { + line := src.relationLine(relationName, typeLine) + fs = append(fs, missingRelation(target, typeName, relationName).at(src, line).in(file, module)) } } if ttu := userset.GetTupleToUserset(); ttu != nil { - validateTupleToUsersetReferences(collector, validator, typeName, relationName, ttu, meta, typeLineIndex, lines) + fs = append(fs, validateTupleToUsersetReferences(idx, src, typeDef, relationName, ttu, typeLine)...) } if union := userset.GetUnion(); union != nil { for _, child := range union.GetChild() { - validateUsersetReferences(collector, validator, typeName, relationName, child, typeLineIndex, lines) + fs = append(fs, validateUsersetReferences(idx, src, typeDef, relationName, child, typeLine)...) } } + if intersection := userset.GetIntersection(); intersection != nil { for _, child := range intersection.GetChild() { - validateUsersetReferences(collector, validator, typeName, relationName, child, typeLineIndex, lines) + fs = append(fs, validateUsersetReferences(idx, src, typeDef, relationName, child, typeLine)...) } } + if diff := userset.GetDifference(); diff != nil { - validateUsersetReferences(collector, validator, typeName, relationName, diff.GetBase(), typeLineIndex, lines) - validateUsersetReferences(collector, validator, typeName, relationName, diff.GetSubtract(), typeLineIndex, lines) + fs = append(fs, validateUsersetReferences(idx, src, typeDef, relationName, diff.GetBase(), typeLine)...) + fs = append(fs, validateUsersetReferences(idx, src, typeDef, relationName, diff.GetSubtract(), typeLine)...) } + + return fs } -// validateTupleToUsersetReferences validates a `target from from` rewrite, +// validateTupleToUsersetReferences validates a `target from tupleset` rewrite, // mirroring the reference implementation: -// - the `from` (tupleset) relation must exist on the current type; -// - the `from` relation must be a plain direct assignment whose assignable +// - the tupleset relation must exist on the current type; +// - the tupleset relation must be a plain direct assignment whose assignable // types are concrete (no wildcard, no type#relation); -// - the computed `target` relation must exist on at least one of the types the -// `from` relation is assignable to. -func validateTupleToUsersetReferences(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, ttu *openfgav1.TupleToUserset, meta *Meta, typeLineIndex *int, lines []string) { +// - the computed target relation must exist on at least one of the types the +// tupleset relation is assignable to. +func validateTupleToUsersetReferences(idx *index, src source, typeDef *openfgav1.TypeDefinition, + relationName string, ttu *openfgav1.TupleToUserset, typeLine int) []*Finding { fromRelation := ttu.GetTupleset().GetRelation() targetRelation := ttu.GetComputedUserset().GetRelation() + if fromRelation == "" || targetRelation == "" { - return + return nil } - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) + + typeName := typeDef.GetType() + file, module := typeMeta(typeDef) + line := src.relationLine(relationName, typeLine) symbol := targetRelation + " from " + fromRelation - // 1. The `from` relation must exist on the current type. - if !validator.RelationDefined(typeName, fromRelation) { - collector.RaiseInvalidTypeRelation(symbol, typeName, relationName, fromRelation, typeName, lineIndex, meta) - return + // 1. The tupleset relation must exist on the current type. + if !idx.relationDefined(typeName, fromRelation) { + return []*Finding{invalidTypeRelation(symbol, typeName, relationName, fromRelation, typeName). + at(src, line).in(file, module)} } - // 2. The `from` relation must be a single direct assignment. - fromTypes, isValid := validator.GetDirectlyAssignableTypes(typeName, fromRelation) - if !isValid || len(fromTypes) == 0 { - collector.RaiseTupleUsersetRequiresDirect(fromRelation, typeName, relationName, meta, lineIndex) - return + // 2. The tupleset relation must be a single direct assignment. + fromTypes, isDirect := idx.directlyAssignableTypes(typeName, fromRelation) + if !isDirect || len(fromTypes) == 0 { + return []*Finding{tupleUsersetRequiresDirect(fromRelation, typeName, relationName). + atFromClause(src, line).in(file, module)} } - // 3. Each assignable type of `from` must be a concrete type (no wildcard, no - // type#relation), and the computed `target` must exist on at least one of - // them. + // 3. Each assignable type of the tupleset relation must be a concrete type + // (no wildcard, no type#relation), and the computed target must exist on + // at least one of them. + var fs []*Finding + notValid := make([]*openfgav1.RelationReference, 0, len(fromTypes)) - for _, tr := range fromTypes { - decodedType := tr.GetType() - if tr.GetWildcard() != nil || tr.GetRelation() != "" { + + for _, restriction := range fromTypes { + if restriction.GetWildcard() != nil || restriction.GetRelation() != "" { // A wildcard or type#relation cannot be used as a tupleset target. - collector.RaiseTupleUsersetRequiresDirect(fromRelation, typeName, relationName, meta, lineIndex) + fs = append(fs, tupleUsersetRequiresDirect(fromRelation, typeName, relationName). + atFromClause(src, line).in(file, module)) + continue } - if !validator.TypeDefined(decodedType) || !validator.RelationDefined(decodedType, targetRelation) { - notValid = append(notValid, tr) + + targetType := restriction.GetType() + if !idx.typeDefined(targetType) || !idx.relationDefined(targetType, targetRelation) { + notValid = append(notValid, restriction) } } + // If the target is missing on every assignable type, report it per type. if len(notValid) == len(fromTypes) { - for _, tr := range notValid { - collector.RaiseInvalidRelationOnTupleset(symbol, tr.GetType(), typeName, relationName, targetRelation, fromRelation, lineIndex, meta) + for _, restriction := range notValid { + fs = append(fs, invalidRelationOnTupleset(symbol, targetRelation, typeName, fromRelation, + restriction.GetType(), relationName).at(src, line).in(file, module)) } } + + return fs } diff --git a/pkg/go/validation/semantic_validation_test.go b/pkg/go/validation/semantic_validation_test.go deleted file mode 100644 index 977d7582..00000000 --- a/pkg/go/validation/semantic_validation_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package validation - -import ( - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" -) - -func TestSemanticValidator(t *testing.T) { - t.Run("NewSemanticValidator", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - validator := NewSemanticValidator(model) - - assert.NotNil(t, validator) - assert.Equal(t, model, validator.model) - assert.Len(t, validator.typeMap, 2) - assert.Len(t, validator.relationMap, 1) - }) - - t.Run("TypeDefined", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - {Type: "user"}, - }, - } - - validator := NewSemanticValidator(model) - - assert.True(t, validator.TypeDefined("document")) - assert.True(t, validator.TypeDefined("user")) - assert.False(t, validator.TypeDefined("group")) - assert.False(t, validator.TypeDefined("")) - }) - - t.Run("RelationDefined", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - "editor": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - validator := NewSemanticValidator(model) - - assert.True(t, validator.RelationDefined("document", "viewer")) - assert.True(t, validator.RelationDefined("document", "editor")) - assert.False(t, validator.RelationDefined("document", "admin")) - assert.False(t, validator.RelationDefined("user", "viewer")) - assert.False(t, validator.RelationDefined("group", "viewer")) - }) - - t.Run("GetTypeDefinition", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - {Type: "user"}, - }, - } - - validator := NewSemanticValidator(model) - - docType := validator.GetTypeDefinition("document") - assert.NotNil(t, docType) - assert.Equal(t, "document", docType.Type) - - userType := validator.GetTypeDefinition("user") - assert.NotNil(t, userType) - assert.Equal(t, "user", userType.Type) - - groupType := validator.GetTypeDefinition("group") - assert.Nil(t, groupType) - }) -} - -func TestValidateRelationReferences(t *testing.T) { - t.Run("Valid references", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Empty(t, errors) - }) - - t.Run("Undefined type in restriction", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "undefined_type"}, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, InvalidType, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_type") - }) - - t.Run("Undefined relation in restriction", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "undefined_relation"}}, - }, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, InvalidRelationType, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_relation") - }) - - t.Run("Undefined relation in computed userset", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "undefined_relation", - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_relation") - }) - - t.Run("Complex userset validation", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_Union{ - Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "editor", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "undefined_relation", - }, - }, - }, - }, - }, - }, - }, - "editor": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_relation") - }) -} diff --git a/pkg/go/validation/source.go b/pkg/go/validation/source.go new file mode 100644 index 00000000..827b9340 --- /dev/null +++ b/pkg/go/validation/source.go @@ -0,0 +1,266 @@ +package validation + +import ( + "regexp" + "strings" +) + +// source is the DSL text findings are located in. The zero value is a model +// that arrived as JSON: there is no text, so every lookup reports absent and +// every stamp is a no-op, and findings carry no position. +type source struct { + lines []string +} + +func newSource(dsl string) source { + return source{lines: strings.Split(dsl, "\n")} +} + +// foldInline collapses every run of inline whitespace into one space, so +// `define\towner:` is matched like `define owner:`. Only space, tab and form +// feed fold — exactly the lexer's WHITESPACE alphabet (OpenFGALexer.g4); +// anything else fails to lex and can never reach a line lookup. Lines already +// normal are returned unchanged. Matching only: columns are still resolved +// against the raw line, so folding never shifts a reported position. +// +// TODO(SoulPancake): replace with utils.NormalizeWhitespace once #652 merges, +// so the repo has one whitespace-folder. +func foldInline(line string) string { + folded := false + + for i := 0; i < len(line); i++ { + if b := line[i]; b == '\t' || b == '\f' || (b == ' ' && i > 0 && line[i-1] == ' ') { + folded = true + + break + } + } + + if !folded { + return line + } + + var b strings.Builder + b.Grow(len(line)) + + inRun := false + + for i := 0; i < len(line); i++ { + if c := line[i]; c == ' ' || c == '\t' || c == '\f' { + if !inRun { + b.WriteByte(' ') + } + + inRun = true + } else { + b.WriteByte(c) + inRun = false + } + } + + return b.String() +} + +// typeLine returns the line index a type is declared on, or -1 when the source +// does not declare it. +func (s source) typeLine(typeName string) int { + for i, line := range s.lines { + trimmed := foldInline(strings.TrimSpace(line)) + if !strings.HasPrefix(trimmed, "type ") { + continue + } + + if fields := strings.Fields(trimmed); len(fields) >= 2 && fields[1] == typeName { + return i + } + } + + return -1 +} + +// relationLine returns the line index a relation is defined on, searching from +// the given line so the right `define` is found when several types declare a +// relation of the same name. A negative from searches the whole source. +func (s source) relationLine(relationName string, from int) int { + if from < 0 { + from = 0 + } + + for i := from; i < len(s.lines); i++ { + trimmed := foldInline(strings.TrimSpace(s.lines[i])) + if !strings.HasPrefix(trimmed, "define ") { + continue + } + + definePart := strings.TrimPrefix(trimmed, "define ") + + colon := strings.Index(definePart, ":") + if colon > 0 && strings.TrimSpace(definePart[:colon]) == relationName { + return i + } + } + + return -1 +} + +// conditionLine returns the line index a condition is declared on, or -1. The +// parameter list's `(` must follow the name, so a condition whose name is a +// prefix of another (e.g. `less` vs `less_than`) cannot match the wrong line. +func (s source) conditionLine(conditionName string) int { + prefix := "condition " + conditionName + + for i, line := range s.lines { + trimmed := foldInline(strings.TrimSpace(line)) + if !strings.HasPrefix(trimmed, prefix) { + continue + } + + if strings.HasPrefix(strings.TrimLeft(trimmed[len(prefix):], " \t"), "(") { + return i + } + } + + return -1 +} + +// multiSpaceRegex collapses runs of whitespace when normalizing a DSL line for +// schema-version matching; foldInline is not used here because the pattern's +// own \s+ already admits any single separator and this predates it. Hoisted so +// it is compiled once, not per line. +var multiSpaceRegex = regexp.MustCompile(`\s{2,}`) + +// schemaLine returns the line index the schema version is declared on, or -1. +// +// A trailing comment may follow the version, as in `schema 1.1 # note`. The `#` +// has to be preceded by whitespace, so one written against the version is part +// of the version and does not match here. +func (s source) schemaLine(schemaVersion string) int { + if len(s.lines) == 0 { + return -1 + } + + pattern := regexp.MustCompile(`^\s*schema\s+` + regexp.QuoteMeta(schemaVersion) + `(\s+#.*)?\s*$`) + + for i, line := range s.lines { + normalized := multiSpaceRegex.ReplaceAllString(strings.TrimSpace(line), " ") + if pattern.MatchString(normalized) { + return i + } + } + + return -1 +} + +// at stamps the position a finding points at: the given line, and the column +// its symbol sits at on that line. No other code computes positions. +// A no-op for a nil finding and for a line the source does not have, which +// covers both a failed line search (-1) and a model with no source text. +// Chainable, so a raise site reads `invalidType(name).at(src, line).in(file, module)`. +func (f *Finding) at(src source, line int) *Finding { + if f == nil || line < 0 || line >= len(src.lines) { + return f + } + + f.Line = &Range{Start: line, End: line} + + col := wordIndex(src.lines[line], f.Metadata.Symbol) + f.Column = &Range{Start: col, End: col + len(f.Metadata.Symbol)} + + return f +} + +// atFromClause is at, with the column searched after the `from` keyword so it +// marks the offending tupleset relation rather than an earlier occurrence of +// the same name on the line. +func (f *Finding) atFromClause(src source, line int) *Finding { + if f == nil { + return nil + } + + f.at(src, line) + + if f.Column == nil { + return f + } + + rawLine := src.lines[line] + if clause := strings.Index(rawLine, "from"); clause >= 0 { + col := clause + len("from") + strings.Index(rawLine[clause+len("from"):], f.Metadata.Symbol) + f.Column = &Range{Start: col, End: col + len(f.Metadata.Symbol)} + } + + return f +} + +// atRestriction is at, with the column searched on the value side of the `:` so +// it marks the type restriction rather than a relation key sharing its name. +func (f *Finding) atRestriction(src source, line int) *Finding { + if f == nil { + return nil + } + + f.at(src, line) + + if f.Column == nil { + return f + } + + rawLine := src.lines[line] + if colon := strings.Index(rawLine, ":"); colon >= 0 { + col := colon + 1 + wordIndex(rawLine[colon+1:], f.Metadata.Symbol) + f.Column = &Range{Start: col, End: col + len(f.Metadata.Symbol)} + } + + return f +} + +// wordIndex returns the index of symbol in rawLine matched on word boundaries, +// mirroring the reference's `\bsymbol\b` lookup. This avoids matching a symbol +// as a substring of another word (e.g. finding `t` inside `type`). Returns 0 +// when the symbol is not found, matching the reference's fallback. +// +// The boundary check is done directly rather than via a per-call compiled +// regexp: `\b` only requires that the characters flanking the match are not +// word characters, which is cheap to test in place and avoids recompiling a +// pattern for every finding. +func wordIndex(rawLine, symbol string) int { + if symbol == "" { + return 0 + } + // Only attempt a word-boundary match when the symbol begins and ends with a + // word character; symbols containing non-word characters (e.g. `user:*`) + // can't match `\bsymbol\b` and fall through to the substring search. + if isWordChar(symbol[0]) && isWordChar(symbol[len(symbol)-1]) { + for off := 0; ; { + idx := strings.Index(rawLine[off:], symbol) + if idx < 0 { + break + } + + pos := off + idx + beforeOK := pos == 0 || !isWordChar(rawLine[pos-1]) + afterPos := pos + len(symbol) + afterOK := afterPos == len(rawLine) || !isWordChar(rawLine[afterPos]) + + if beforeOK && afterOK { + return pos + } + + off = pos + 1 + } + } + + if idx := strings.Index(rawLine, symbol); idx >= 0 { + return idx + } + + return 0 +} + +// isWordChar reports whether b is a regexp `\w` character ([0-9A-Za-z_]). +func isWordChar(b byte) bool { + return b == '_' || + (b >= '0' && b <= '9') || + (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') +} diff --git a/pkg/go/validation/source_test.go b/pkg/go/validation/source_test.go new file mode 100644 index 00000000..dc13c3c4 --- /dev/null +++ b/pkg/go/validation/source_test.go @@ -0,0 +1,160 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSourceTypeLine(t *testing.T) { + t.Parallel() + + src := newSource("model\n schema 1.1\ntype user\ntype document\n relations\n define viewer: [user]") + + assert.Equal(t, 2, src.typeLine("user")) + assert.Equal(t, 3, src.typeLine("document")) + assert.Equal(t, -1, src.typeLine("missing")) + assert.Equal(t, -1, source{}.typeLine("user"), "no source, no line") +} + +func TestSourceRelationLine(t *testing.T) { + t.Parallel() + + src := newSource(`model + schema 1.1 +type user +type folder + relations + define viewer: [user] +type document + relations + define viewer: [user]`) + + assert.Equal(t, 5, src.relationLine("viewer", -1), "negative from searches the whole source") + assert.Equal(t, 8, src.relationLine("viewer", src.typeLine("document")), + "anchoring to the type finds its own define") + assert.Equal(t, -1, src.relationLine("missing", -1)) +} + +func TestSourceConditionLine(t *testing.T) { + t.Parallel() + + src := newSource(`model + schema 1.1 +type user +condition less(x: int) { + x < 5 +} +condition less_than(x: int) { + x < 10 +}`) + + assert.Equal(t, 3, src.conditionLine("less"), "a name that prefixes another must not match its line") + assert.Equal(t, 6, src.conditionLine("less_than")) + assert.Equal(t, -1, src.conditionLine("missing")) +} + +func TestSourceSchemaLine(t *testing.T) { + t.Parallel() + + assert.Equal(t, 1, newSource("model\n schema 1.0\ntype user").schemaLine("1.0")) + assert.Equal(t, 1, newSource("model\n schema 1.0 # retired\ntype user").schemaLine("1.0"), + "a trailing comment and repeated spaces still match") + assert.Equal(t, -1, newSource("model\n schema 1.1\ntype user").schemaLine("1.0")) + assert.Equal(t, -1, source{}.schemaLine("1.0")) +} + +func TestWordIndex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawLine string + symbol string + want int + }{ + {"word boundary skips substring", " define type_a: [t]", "t", 20}, + {"plain match", " define viewer: [user]", "viewer", 11}, + {"symbol with non-word characters", " define viewer: [user:*]", "user:*", 20}, + {"missing symbol falls back to zero", " define viewer: [user]", "absent", 0}, + {"empty symbol", "anything", "", 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.want, wordIndex(test.rawLine, test.symbol)) + }) + } +} + +func TestFindingAt(t *testing.T) { + t.Parallel() + + src := newSource("model\n schema 1.1\ntype document\n relations\n define viewer: [user]") + + t.Run("stamps line and column", func(t *testing.T) { + t.Parallel() + + finding := (&Finding{Metadata: Metadata{Symbol: "viewer"}}).at(src, 4) + + require.NotNil(t, finding.Line) + require.NotNil(t, finding.Column) + assert.Equal(t, Range{Start: 4, End: 4}, *finding.Line) + assert.Equal(t, Range{Start: 11, End: 17}, *finding.Column, "column is half-open over the symbol") + }) + + t.Run("no source means no position", func(t *testing.T) { + t.Parallel() + + finding := (&Finding{Metadata: Metadata{Symbol: "viewer"}}).at(source{}, 0) + + assert.Nil(t, finding.Line) + assert.Nil(t, finding.Column) + }) + + t.Run("failed line search means no position", func(t *testing.T) { + t.Parallel() + + finding := (&Finding{Metadata: Metadata{Symbol: "viewer"}}).at(src, -1) + + assert.Nil(t, finding.Line) + assert.Nil(t, finding.Column) + }) + + t.Run("nil finding stays nil", func(t *testing.T) { + t.Parallel() + + var finding *Finding + assert.Nil(t, finding.at(src, 0)) + }) +} + +func TestFindingAtFromClause(t *testing.T) { + t.Parallel() + + // `owner` appears both as the define target side and after `from`; the + // from-clause stamp must mark the occurrence after `from`. + src := newSource(" define viewer: owner from owner") + + finding := (&Finding{Metadata: Metadata{Symbol: "owner"}}).atFromClause(src, 0) + + require.NotNil(t, finding.Column) + assert.Equal(t, 30, finding.Column.Start) + assert.Equal(t, 35, finding.Column.End) +} + +func TestFindingAtRestriction(t *testing.T) { + t.Parallel() + + // `group` is both the relation name and the restricted type; the + // restriction stamp must mark the occurrence after the colon. + src := newSource(" define group: [group]") + + finding := (&Finding{Metadata: Metadata{Symbol: "group"}}).atRestriction(src, 0) + + require.NotNil(t, finding.Column) + assert.Equal(t, 19, finding.Column.Start) + assert.Equal(t, 24, finding.Column.End) +} diff --git a/pkg/go/validation/test_helpers_test.go b/pkg/go/validation/test_helpers_test.go deleted file mode 100644 index 4491b0d6..00000000 --- a/pkg/go/validation/test_helpers_test.go +++ /dev/null @@ -1,4 +0,0 @@ -package validation - -func ptrString(s string) *string { return &s } -func ptrInt(i int) *int { return &i } diff --git a/pkg/go/validation/validate.go b/pkg/go/validation/validate.go new file mode 100644 index 00000000..c8bc5d48 --- /dev/null +++ b/pkg/go/validation/validate.go @@ -0,0 +1,73 @@ +package validation + +import ( + "errors" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +// ValidateDSL runs every validation over model, using dsl — the source text the +// model was parsed from — to resolve each finding's position. The model is the +// already-parsed proto; nothing here parses. +// +// It returns nil for a valid model. Otherwise the error joins every finding in +// the order raised, which ExtractAllAs recovers: +// +// for _, finding := range validation.ExtractAllAs[*validation.Finding](err) { +// ... +// } +func ValidateDSL(model *openfgav1.AuthorizationModel, dsl string) error { + return validate(model, newSource(dsl)) +} + +// ValidateJSON runs every validation over a model that reached the caller as +// JSON, so with no DSL source text behind it. Findings carry a nil Line and +// Column; the messages and metadata are what ValidateDSL reports for the same +// model. The name matches pkg/js's validateJSON and pkg/java's validateJson. +func ValidateJSON(model *openfgav1.AuthorizationModel) error { + return validate(model, source{}) +} + +// validate runs the validation phases in the reference implementation's order. +// +// Schema, name and reference validation always run. The later structural +// phases are gated on nothing having been found yet: a model with bad +// references or duplicates would otherwise produce a cascade of derived +// entry-point and operation errors for the same root cause. This mirrors the +// reference's modelValidation, which skips the later passes once any error has +// been recorded. Multi-file and condition checks are independent of the cascade +// and always run, matching the reference's handling of conditions. +func validate(model *openfgav1.AuthorizationModel, src source) error { + if model == nil { + return nil + } + + idx := newIndex(model) + + var errs []error + add := func(err error) { + if err != nil { + errs = append(errs, err) + } + } + + add(validateSchemaVersion(model, src)) + add(validateNames(model, src)) + add(validateRelationReferences(idx, src)) + + if len(errs) == 0 { + add(validateDuplicates(model, src)) + } + + if len(errs) == 0 { + add(validateEntryPoints(idx, src)) + add(validateTupleToUsersets(idx, src)) + add(validateComplexOperations(idx, src)) + add(validateWildcards(idx, src)) + } + + add(validateMultiFile(model)) + add(validateConditions(model, src)) + + return errors.Join(errs...) +} diff --git a/pkg/go/validation/validate_test.go b/pkg/go/validation/validate_test.go new file mode 100644 index 00000000..b53a5440 --- /dev/null +++ b/pkg/go/validation/validate_test.go @@ -0,0 +1,256 @@ +package validation + +import ( + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openfga/language/pkg/go/transformer" +) + +// modelWithRelations builds the smallest model the phases accept: one type +// whose relations are all direct assignments. It bypasses the parser, which +// would reject the invalid names these tests feed in. +func modelWithRelations(t *testing.T, typeName string, relationNames ...string) *openfgav1.AuthorizationModel { + t.Helper() + + relations := make(map[string]*openfgav1.Userset, len(relationNames)) + for _, relationName := range relationNames { + relations[relationName] = &openfgav1.Userset{ + Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, + } + } + + return &openfgav1.AuthorizationModel{ + SchemaVersion: "1.1", + TypeDefinitions: []*openfgav1.TypeDefinition{ + {Type: typeName, Relations: relations}, + }, + } +} + +// mustParse transforms a DSL that is expected to be grammatical. +func mustParse(t *testing.T, dsl string) *openfgav1.AuthorizationModel { + t.Helper() + + model, err := transformer.TransformDSLToProto(dsl) + require.NoError(t, err) + + return model +} + +func TestValidateDSL(t *testing.T) { + t.Parallel() + + t.Run("valid model returns nil", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user]` + + require.NoError(t, ValidateDSL(mustParse(t, dsl), dsl)) + }) + + t.Run("nil model returns nil", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateDSL(nil, "")) + require.NoError(t, ValidateJSON(nil)) + }) + + t.Run("findings are recovered with ExtractAllAs", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: editor` + + err := ValidateDSL(mustParse(t, dsl), dsl) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) + require.Len(t, findings, 1) + + finding := findings[0] + assert.Equal(t, "the relation `editor` does not exist.", finding.Message) + assert.Equal(t, MissingDefinition, finding.Metadata.Kind) + assert.Equal(t, "editor", finding.Metadata.Symbol) + assert.Equal(t, &Range{Start: 5, End: 5}, finding.Line) + assert.Equal(t, &Range{Start: 19, End: 25}, finding.Column) + }) +} + +func TestValidateJSON(t *testing.T) { + t.Parallel() + + t.Run("valid model returns nil", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user]` + + require.NoError(t, ValidateJSON(mustParse(t, dsl))) + }) + + t.Run("findings carry no position", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: editor` + + err := ValidateJSON(mustParse(t, dsl)) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) + require.Len(t, findings, 1) + assert.Equal(t, "the relation `editor` does not exist.", findings[0].Message) + assert.Nil(t, findings[0].Line) + assert.Nil(t, findings[0].Column) + }) +} + +// TestValidateCascadeGate pins the phase gating: once an earlier phase reports, +// the structural phases (duplicates, entry points, operations, wildcards) are +// skipped, so one root cause is not reported as a cascade of derived findings. +func TestValidateCascadeGate(t *testing.T) { + t.Parallel() + + t.Run("a duplicate suppresses derived entry-point findings", func(t *testing.T) { + t.Parallel() + + // document is declared twice, and its viewer computes itself — a + // no-entry-point loop that must NOT be reported alongside the duplicate. + typeDef := func() *openfgav1.TypeDefinition { + return &openfgav1.TypeDefinition{ + Type: "document", + Relations: map[string]*openfgav1.Userset{ + "viewer": {Userset: &openfgav1.Userset_ComputedUserset{ + ComputedUserset: &openfgav1.ObjectRelation{Relation: "viewer"}, + }}, + }, + } + } + model := &openfgav1.AuthorizationModel{ + SchemaVersion: "1.1", + TypeDefinitions: []*openfgav1.TypeDefinition{typeDef(), typeDef()}, + } + + findings := ExtractAllAs[*Finding](validate(model, source{})) + + require.Len(t, findings, 1) + assert.Equal(t, DuplicatedError, findings[0].Metadata.Kind) + }) + + t.Run("an entry-point loop is reported when nothing gates it", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: editor + define editor: viewer` + + err := ValidateDSL(mustParse(t, dsl), dsl) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) + require.Len(t, findings, 2) + + for _, finding := range findings { + assert.Equal(t, RelationNoEntrypoint, finding.Metadata.Kind) + assert.Contains(t, finding.Message, "(potential loop)") + } + }) + + t.Run("condition checks run even when the cascade is gated", func(t *testing.T) { + t.Parallel() + + // The undefined type gates the structural phases, but the unused + // condition must still be reported, matching the reference. + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [ghost] +condition marked(x: int) { + x > 0 +}` + + err := ValidateDSL(mustParse(t, dsl), dsl) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) + require.Len(t, findings, 2) + + assert.Equal(t, InvalidType, findings[0].Metadata.Kind) + assert.Equal(t, ConditionNotUsed, findings[1].Metadata.Kind) + }) +} + +// TestValidateFileAndModule pins where file and module come from: the proto's +// source info, per declaration, not the validated text. +func TestValidateFileAndModule(t *testing.T) { + t.Parallel() + + model := modelWithRelations(t, "self") + model.TypeDefinitions[0].Metadata = &openfgav1.Metadata{ + Module: "core", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + } + + findings := ExtractAllAs[*Finding](validate(model, source{})) + + require.Len(t, findings, 1) + assert.Equal(t, "core.fga", findings[0].File) + assert.Equal(t, "core", findings[0].Metadata.Module) +} + +// TestValidateMultiFile pins the one multi-file rule: a file that would carry +// two modules is reported, with the modules listed in model order. +func TestValidateMultiFile(t *testing.T) { + t.Parallel() + + model := &openfgav1.AuthorizationModel{ + SchemaVersion: "1.1", + TypeDefinitions: []*openfgav1.TypeDefinition{ + {Type: "user", Metadata: &openfgav1.Metadata{ + Module: "core", + SourceInfo: &openfgav1.SourceInfo{File: "shared.fga"}, + }}, + {Type: "document", Metadata: &openfgav1.Metadata{ + Module: "docs", + SourceInfo: &openfgav1.SourceInfo{File: "shared.fga"}, + }}, + }, + } + + findings := ExtractAllAs[*Finding](validateMultiFile(model)) + + require.Len(t, findings, 1) + assert.Equal(t, MultipleModulesInFile, findings[0].Metadata.Kind) + assert.Equal(t, + "file shared.fga would contain multiple module definitions (core, docs) when transforming to DSL. "+ + "Only one module can be defined per file.", + findings[0].Message) + assert.Nil(t, findings[0].Line) +} diff --git a/pkg/go/validation/validation_engine.go b/pkg/go/validation/validation_engine.go deleted file mode 100644 index 31e616ea..00000000 --- a/pkg/go/validation/validation_engine.go +++ /dev/null @@ -1,224 +0,0 @@ -package validation - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// ValidationEngine is the main entry point for all validation operations. -type ValidationEngine struct { - model *openfgav1.AuthorizationModel - lines []string - collector *ErrorCollector - // semantic and condition index the model once and are shared across every - // phase that needs them, rather than each phase rebuilding its own. - semantic *SemanticValidator - condition *ConditionValidator -} - -// EngineOptions configures validation behavior. -type EngineOptions struct { - SkipSemanticValidation bool - SkipComplexOperationValidation bool - SkipWildcardValidation bool - SkipMultiFileValidation bool - SkipConditionValidation bool -} - -func DefaultEngineOptions() *EngineOptions { - return &EngineOptions{} -} - -func NewValidationEngine(model *openfgav1.AuthorizationModel, dslContent string) *ValidationEngine { - lines := strings.Split(dslContent, "\n") - collector := NewErrorCollector(lines) - ve := &ValidationEngine{model: model, lines: lines, collector: collector} - if model != nil { - ve.semantic = NewSemanticValidator(model) - ve.condition = NewConditionValidator(model) - } - return ve -} - -// ValidateDSL validates a DSL model with all available validations. -func ValidateDSL(model *openfgav1.AuthorizationModel, dslContent string, options *EngineOptions) *ValidationErrors { - if options == nil { - options = DefaultEngineOptions() - } - return NewValidationEngine(model, dslContent).RunAllValidations(options) -} - -// ValidateJSON validates a JSON model. -func ValidateJSON(model *openfgav1.AuthorizationModel, options *EngineOptions) *ValidationErrors { - if options == nil { - options = DefaultEngineOptions() - } - return NewValidationEngine(model, "").RunAllValidations(options) -} - -// RunAllValidations executes all validation phases in the correct order. -func (ve *ValidationEngine) RunAllValidations(options *EngineOptions) *ValidationErrors { - if ve.model == nil { - return NewValidationErrors(nil) - } - - // Schema and name validation run first and unconditionally. - ve.runSchemaValidation() - ve.runNameValidation() - - // Relation-reference validation always runs. The phases that follow are - // gated on there being no errors yet: a model with bad references or - // duplicates would otherwise produce a cascade of derived entry-point and - // complex-operation errors for the same root cause. This mirrors the - // reference implementation's modelValidation, which skips the later passes - // once any error has been recorded. - if !options.SkipSemanticValidation { - validateRelationReferences(ve.collector, ve.semantic, ve.lines) - } - - if !ve.collector.HasErrors() { - ve.runDuplicateDetection() - } - - if !ve.collector.HasErrors() { - if !options.SkipSemanticValidation { - validateCyclesAndEntryPoints(ve.collector, ve.semantic, ve.lines) - validateTupleToUsersetRequirements(ve.collector, ve.semantic, ve.lines) - } - if !options.SkipComplexOperationValidation { - validateComplexOperations(ve.collector, ve.semantic, ve.lines) - } - if !options.SkipWildcardValidation { - validateWildcardUsage(ve.collector, ve.semantic, ve.lines) - } - } - - // Multi-file and condition checks are independent of the cascade and always - // run, matching the reference's handling of conditions. - if !options.SkipMultiFileValidation { - ve.runMultiFileValidation() - } - if !options.SkipConditionValidation { - ve.runConditionValidation() - } - - return NewValidationErrors(ve.collector.GetErrors()) -} - -func (ve *ValidationEngine) runSchemaValidation() { - ValidateSchemaVersion(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runNameValidation() { - ValidateNames(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runDuplicateDetection() { - ValidateDuplicates(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runMultiFileValidation() { - ValidateMultiFileConsistency(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runConditionValidation() { - validateConditionReferences(ve.collector, ve.condition, ve.lines) - ValidateConditionConsistency(ve.collector, ve.model, ve.lines) - validateUnusedConditions(ve.collector, ve.condition, ve.lines) -} - -// ValidateModel is a convenience function that validates a model with default options. -func ValidateModel(model *openfgav1.AuthorizationModel, dslContent string) *ValidationErrors { - return ValidateDSL(model, dslContent, DefaultEngineOptions()) -} - -// ValidateModelJSON is a convenience function that validates a JSON model with default options. -func ValidateModelJSON(model *openfgav1.AuthorizationModel) *ValidationErrors { - return ValidateJSON(model, DefaultEngineOptions()) -} - -func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { - errors := ve.collector.GetErrors() - summary := ValidationSummary{ - TotalErrors: len(errors), - ErrorsByType: make(map[ValidationErrorType]int), - ErrorsByFile: make(map[string]int), - HasCriticalErrors: false, - } - for _, err := range errors { - if err == nil || err.Metadata == nil { - // Metadata is always set by the collector, but a directly-constructed - // error (e.g. in a consumer or test) could omit it; don't panic. - continue - } - summary.ErrorsByType[err.Metadata.ErrorType]++ - if err.File != "" { - summary.ErrorsByFile[err.File]++ - } - if ve.isCriticalError(err.Metadata.ErrorType) { - summary.HasCriticalErrors = true - } - } - return summary -} - -// ValidationSummary provides a high-level overview of validation results. -type ValidationSummary struct { - TotalErrors int - ErrorsByType map[ValidationErrorType]int - ErrorsByFile map[string]int - HasCriticalErrors bool -} - -// criticalErrorTypes is the fixed set of error types considered critical. It is -// a package-level lookup table so it isn't rebuilt on every isCriticalError call -// (which runs once per error while summarizing). -var criticalErrorTypes = map[ValidationErrorType]bool{ - RelationNoEntrypoint: true, - CyclicRelation: true, - UndefinedType: true, - UndefinedRelation: true, - InvalidRelationType: true, - DuplicatedError: true, - InvalidSchema: true, - InvalidSchemaVersion: true, - MultipleModulesInFile: true, -} - -func (ve *ValidationEngine) isCriticalError(errorType ValidationErrorType) bool { - return criticalErrorTypes[errorType] -} - -// CreateValidationReport creates a detailed validation report. -func CreateValidationReport(model *openfgav1.AuthorizationModel, dslContent string, options *EngineOptions) ValidationReport { - engine := NewValidationEngine(model, dslContent) - validationErrors := engine.RunAllValidations(options) - summary := engine.GetValidationSummary() - return ValidationReport{ - Model: model, - ValidationErrors: validationErrors, - Summary: summary, - Options: options, - } -} - -// ValidationReport contains comprehensive validation results. -type ValidationReport struct { - Model *openfgav1.AuthorizationModel - ValidationErrors *ValidationErrors - Summary ValidationSummary - Options *EngineOptions -} - -func (vr *ValidationReport) IsValid() bool { return vr.ValidationErrors.Count() == 0 } -func (vr *ValidationReport) HasCriticalErrors() bool { return vr.Summary.HasCriticalErrors } -func (vr *ValidationReport) GetErrorsByType(errorType ValidationErrorType) []*ValidationError { - var matchingErrors []*ValidationError - for _, err := range vr.ValidationErrors.GetErrors() { - if err.Metadata.ErrorType == errorType { - matchingErrors = append(matchingErrors, err) - } - } - return matchingErrors -} diff --git a/pkg/go/validation/validation_engine_test.go b/pkg/go/validation/validation_engine_test.go deleted file mode 100644 index 48b28dd3..00000000 --- a/pkg/go/validation/validation_engine_test.go +++ /dev/null @@ -1,498 +0,0 @@ -package validation - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// TestValidationEngine_BasicIntegration tests the basic integration of all validation components -func TestValidationEngine_BasicIntegration(t *testing.T) { - t.Run("Valid model passes all validations", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "user", - }, - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - "editor": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "viewer"}}}, - }, - }}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "editor": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define editor: [user] or viewer -` - - // Test ValidateDSL - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) - assert.Equal(t, 0, errors.Count()) - - // Test ValidateJSON - jsonErrors := ValidateJSON(model, DefaultEngineOptions()) - assert.NotNil(t, jsonErrors) - assert.Equal(t, 0, jsonErrors.Count()) - - // Test convenience functions - modelErrors := ValidateModel(model, dslContent) - assert.NotNil(t, modelErrors) - assert.Equal(t, 0, modelErrors.Count()) - - jsonModelErrors := ValidateModelJSON(model) - assert.NotNil(t, jsonModelErrors) - assert.Equal(t, 0, jsonModelErrors.Count()) - }) - - t.Run("Model with validation errors", func(t *testing.T) { - // Create model with various validation issues - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.0", // Older schema version - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "nonexistent"}}, - }, - }, - }, - { - Type: "document", // Duplicate type - Relations: map[string]*openfgav1.Userset{ - "editor": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.0 - -type document - relations - define viewer: nonexistent - define editor: [user] - -type document - relations - define admin: [user] -` - - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) - assert.Greater(t, errors.Count(), 0) - - // Check that we have various types of errors - errorList := errors.GetErrors() - errorTypes := make(map[ValidationErrorType]bool) - for _, err := range errorList { - errorTypes[err.Metadata.ErrorType] = true - } - - // Should have duplicate errors - assert.True(t, len(errorTypes) > 0, "Should have validation errors") - }) -} - -// TestValidationEngine_OptionsConfiguration tests different validation options -func TestValidationEngine_OptionsConfiguration(t *testing.T) { - t.Run("Skip semantic validation", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "nonexistent"}}, - }, - }, - }, - }, - } - - // With semantic validation (default) - normalErrors := ValidateDSL(model, "", DefaultEngineOptions()) - normalErrorCount := normalErrors.Count() - - // Skip semantic validation - options := &EngineOptions{ - SkipSemanticValidation: true, - } - skippedErrors := ValidateDSL(model, "", options) - skippedErrorCount := skippedErrors.Count() - - // Should have fewer errors when semantic validation is skipped - assert.True(t, skippedErrorCount <= normalErrorCount, "Skipping semantic validation should reduce or maintain error count") - }) - - t.Run("Skip complex operation validation", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - }, - }}, - }, - }, - }, - }, - } - - options := &EngineOptions{ - SkipComplexOperationValidation: true, - } - errors := ValidateDSL(model, "", options) - assert.NotNil(t, errors) - // Complex operation validation should be skipped - }) -} - -// TestValidationReport tests the comprehensive validation report functionality -func TestValidationReport(t *testing.T) { - t.Run("Complete validation report", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "user", - }, - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] -` - - report := CreateValidationReport(model, dslContent, DefaultEngineOptions()) - - assert.NotNil(t, report.Model) - assert.NotNil(t, report.ValidationErrors) - assert.NotNil(t, report.Options) - assert.Equal(t, model, report.Model) - - // Test report methods - assert.True(t, report.IsValid(), "Valid model should pass IsValid()") - assert.False(t, report.HasCriticalErrors(), "Valid model should not have critical errors") - - // Test summary - summary := report.Summary - assert.Equal(t, 0, summary.TotalErrors) - assert.False(t, summary.HasCriticalErrors) - assert.NotNil(t, summary.ErrorsByType) - assert.NotNil(t, summary.ErrorsByFile) - }) - - t.Run("Report with errors", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "invalid", // Invalid schema version - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "nonexistent"}}, - }, - }, - }, - }, - } - - report := CreateValidationReport(model, "", DefaultEngineOptions()) - - if report.ValidationErrors.Count() > 0 { - assert.False(t, report.IsValid(), "Invalid model should fail IsValid()") - - summary := report.Summary - assert.Greater(t, summary.TotalErrors, 0) - - // Test GetErrorsByType functionality - for errorType := range summary.ErrorsByType { - errorsOfType := report.GetErrorsByType(errorType) - assert.Greater(t, len(errorsOfType), 0, "Should find errors of type %s", errorType) - } - } - }) -} - -// TestValidationEngine_RealWorldScenarios tests realistic authorization model scenarios -func TestValidationEngine_RealWorldScenarios(t *testing.T) { - t.Run("GitHub-like authorization model", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "user", - }, - { - Type: "organization", - Relations: map[string]*openfgav1.Userset{ - "member": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - "owner": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "member": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "owner": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - { - Type: "repository", - Relations: map[string]*openfgav1.Userset{ - "reader": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_TupleToUserset{TupleToUserset: &openfgav1.TupleToUserset{ - Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, - ComputedUserset: &openfgav1.ObjectRelation{Relation: "member"}, - }}}, - }, - }}, - }, - "writer": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "admin"}}}, - }, - }}, - }, - "admin": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_TupleToUserset{TupleToUserset: &openfgav1.TupleToUserset{ - Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, - ComputedUserset: &openfgav1.ObjectRelation{Relation: "owner"}, - }}}, - }, - }}, - }, - "owner": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "reader": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "writer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "admin": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "owner": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "organization", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "owner"}}, - }, - }, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.1 - -type user - -type organization - relations - define member: [user] - define owner: [user] - -type repository - relations - define owner: [user, organization#owner] - define admin: [user] or owner from owner - define writer: [user] or admin - define reader: [user] or writer from owner -` - - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) - - // This complex model should pass validation - if errors.Count() > 0 { - t.Logf("Validation errors found: %d", errors.Count()) - for _, err := range errors.GetErrors() { - t.Logf("Error: %s (Type: %s)", err.Message, err.Metadata.ErrorType) - } - } - - // Create validation report - report := CreateValidationReport(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, report) - - t.Logf("Validation Summary:") - t.Logf("- Total Errors: %d", report.Summary.TotalErrors) - t.Logf("- Has Critical Errors: %v", report.Summary.HasCriticalErrors) - t.Logf("- Valid Model: %v", report.IsValid()) - }) -} - -// TestValidationEngine_PerformanceBasics tests basic performance characteristics -func TestValidationEngine_PerformanceBasics(t *testing.T) { - t.Run("Large model validation performance", func(t *testing.T) { - // Create a moderately large model - typeDefs := make([]*openfgav1.TypeDefinition, 0, 50) - - // Add user type - typeDefs = append(typeDefs, &openfgav1.TypeDefinition{Type: "user"}) - - // Add many document types with relations - for i := 0; i < 49; i++ { - typeName := fmt.Sprintf("document%d", i) - relations := make(map[string]*openfgav1.Userset) - relationMetadata := make(map[string]*openfgav1.RelationMetadata) - - // Add viewer relation - relations["viewer"] = &openfgav1.Userset{ - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - } - relationMetadata["viewer"] = &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - } - - // Add editor relation with union - relations["editor"] = &openfgav1.Userset{ - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "viewer"}}}, - }, - }}, - } - relationMetadata["editor"] = &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - } - - typeDefs = append(typeDefs, &openfgav1.TypeDefinition{ - Type: typeName, - Relations: relations, - Metadata: &openfgav1.Metadata{ - Relations: relationMetadata, - }, - }) - } - - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: typeDefs, - } - - // Test validation performance - errors := ValidateDSL(model, "", DefaultEngineOptions()) - assert.NotNil(t, errors) - - // Should complete validation in reasonable time - t.Logf("Large model validation completed with %d errors", errors.Count()) - - // Test JSON validation performance - jsonErrors := ValidateJSON(model, DefaultEngineOptions()) - assert.NotNil(t, jsonErrors) - t.Logf("Large model JSON validation completed with %d errors", jsonErrors.Count()) - }) -} diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go index 8a4ea4e5..7b0320e5 100644 --- a/pkg/go/validation/wildcard_validation.go +++ b/pkg/go/validation/wildcard_validation.go @@ -1,159 +1,131 @@ package validation import ( - "fmt" + "maps" + "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ValidateWildcardUsage validates wildcard relation usage rules. -func ValidateWildcardUsage(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateWildcardUsage(collector, NewSemanticValidator(model), lines) -} +// validateWildcards checks every wildcard type restriction: the type it +// restricts must exist, and a restriction cannot carry both a wildcard and a +// relation. +func validateWildcards(idx *index, src source) error { + var fs []*Finding -func validateWildcardUsage(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - for _, typeDef := range model.GetTypeDefinitions() { + for _, typeDef := range idx.model.GetTypeDefinitions() { if typeDef.GetMetadata() == nil { continue } - for relationName, relationMetadata := range typeDef.GetMetadata().GetRelations() { - validateWildcardInRelation(collector, validator, typeDef.GetType(), relationName, relationMetadata, lines) - } - } -} -func validateWildcardInRelation(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, relationMetadata *openfgav1.RelationMetadata, lines []string) { - if relationMetadata == nil { - return - } - meta := &Meta{ - File: relationMetadata.GetSourceInfo().GetFile(), - Module: relationMetadata.GetModule(), - } - // Anchor relation line lookups to this type's declaration so the correct - // `define` is found when several types share a relation name. - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - for _, typeRestriction := range relationMetadata.GetDirectlyRelatedUserTypes() { - if typeRestriction.GetType() == "" { - continue - } - if typeRestriction.GetWildcard() != nil { - validateWildcardRestriction(collector, validator, typeRestriction, relationName, typeName, meta, lines, typeLineIndex) - // wildcard and explicit relation together is invalid - if typeRestriction.GetRelation() != "" { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseInvalidWildcardUsage(typeRestriction.GetType(), relationName, typeName, - "wildcard cannot be used with specific relation", meta, lineIndex) + typeName := typeDef.GetType() + + // Anchor relation line lookups to this type's declaration so the correct + // `define` is found when several types share a relation name. + typeLine := src.typeLine(typeName) + + relationsMetadata := typeDef.GetMetadata().GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + relationMetadata := relationsMetadata[relationName] + if relationMetadata == nil { + continue + } + + file := relationMetadata.GetSourceInfo().GetFile() + module := relationMetadata.GetModule() + + for _, restriction := range relationMetadata.GetDirectlyRelatedUserTypes() { + if restriction.GetType() == "" || restriction.GetWildcard() == nil { + continue + } + + if !idx.typeDefined(restriction.GetType()) { + line := src.relationLine(relationName, typeLine) + fs = append(fs, undefinedType(restriction.GetType(), relationName, typeName). + at(src, line).in(file, module)) + } + + // A wildcard and an explicit relation together is invalid. + if restriction.GetRelation() != "" { + line := src.relationLine(relationName, typeLine) + fs = append(fs, invalidWildcardUsage(restriction.GetType(), relationName, typeName, + "wildcard cannot be used with specific relation").at(src, line).in(file, module)) + } } } } -} -func validateWildcardRestriction(collector *ErrorCollector, validator *SemanticValidator, - typeRestriction *openfgav1.RelationReference, relationName, typeName string, meta *Meta, lines []string, typeLineIndex *int) { - if !validator.TypeDefined(typeRestriction.GetType()) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseUndefinedType(typeRestriction.GetType(), relationName, typeName, meta, lineIndex) - } + return joinFindings(fs...) } -// ValidateTupleToUsersetRequirements validates tuple-to-userset usage requirements. -func ValidateTupleToUsersetRequirements(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateTupleToUsersetRequirements(collector, NewSemanticValidator(model), lines) -} +// validateTupleToUsersets checks that every tupleset relation used in a +// `target from tupleset` rewrite allows direct assignment. Whether the tupleset +// and computed relations exist is the reference phase's job; here an existing +// tupleset relation with no assignable types is reported. +func validateTupleToUsersets(idx *index, src source) error { + var fs []*Finding -func validateTupleToUsersetRequirements(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - for _, typeDef := range model.GetTypeDefinitions() { - for relationName, userset := range typeDef.GetRelations() { - validateTupleToUsersetInUserset(collector, validator, typeDef.GetType(), relationName, userset, lines) + for _, typeDef := range idx.model.GetTypeDefinitions() { + relations := typeDef.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + fs = append(fs, tuplesetsIn(idx, src, typeDef.GetType(), relationName, relations[relationName])...) } } + + return joinFindings(fs...) } -func validateTupleToUsersetInUserset(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, userset *openfgav1.Userset, lines []string) { +// tuplesetsIn walks one relation's rewrite tree and reports each +// tuple-to-userset whose tupleset relation is not directly assignable. +func tuplesetsIn(idx *index, src source, typeName, relationName string, userset *openfgav1.Userset) []*Finding { if userset == nil { - return + return nil } + var fs []*Finding + if ttu := userset.GetTupleToUserset(); ttu != nil { - typeDef := validator.GetTypeDefinition(typeName) - meta := &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - validateTupleToUsersetOperation(collector, validator, typeName, relationName, ttu, meta, lines) + fs = append(fs, tuplesetNotAssignable(idx, src, typeName, relationName, ttu)) } + if union := userset.GetUnion(); union != nil { for _, child := range union.GetChild() { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, child, lines) + fs = append(fs, tuplesetsIn(idx, src, typeName, relationName, child)...) } } + if intersection := userset.GetIntersection(); intersection != nil { for _, child := range intersection.GetChild() { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, child, lines) + fs = append(fs, tuplesetsIn(idx, src, typeName, relationName, child)...) } } + if diff := userset.GetDifference(); diff != nil { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, diff.GetBase(), lines) - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, diff.GetSubtract(), lines) + fs = append(fs, tuplesetsIn(idx, src, typeName, relationName, diff.GetBase())...) + fs = append(fs, tuplesetsIn(idx, src, typeName, relationName, diff.GetSubtract())...) } + + return fs } -func validateTupleToUsersetOperation(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, ttu *openfgav1.TupleToUserset, meta *Meta, lines []string) { +// tuplesetNotAssignable reports a defined tupleset relation that declares no +// directly-related user types, or nil when it declares some or does not exist. +func tuplesetNotAssignable(idx *index, src source, typeName, relationName string, + ttu *openfgav1.TupleToUserset) *Finding { tuplesetRelation := ttu.GetTupleset().GetRelation() - if tuplesetRelation == "" { - return - } - // Whether the tupleset/computed relations exist is validated in the - // relation-reference pass (semantic_validation.go). Here we only check that - // an existing tupleset relation is directly assignable. - if !validator.RelationDefined(typeName, tuplesetRelation) { - return + if tuplesetRelation == "" || !idx.relationDefined(typeName, tuplesetRelation) { + return nil } - validateTuplesetDirectAssignment(collector, validator, typeName, tuplesetRelation, relationName, meta, lines) -} -func validateTuplesetDirectAssignment(collector *ErrorCollector, validator *SemanticValidator, - typeName, tuplesetRelation, parentRelation string, meta *Meta, lines []string) { - typeDef := validator.GetTypeDefinition(typeName) - if typeDef == nil { - return - } - if metaProto := typeDef.GetMetadata(); metaProto != nil { - if rm, ok := metaProto.GetRelations()[tuplesetRelation]; ok { - if len(rm.GetDirectlyRelatedUserTypes()) == 0 { - lineIndex := GetRelationLineNumber(parentRelation, lines, nil) - collector.RaiseTuplesetNotDirect(tuplesetRelation, typeName, parentRelation, meta, lineIndex) - } - } + typeDef := idx.typeDef(typeName) + + relationMetadata, ok := typeDef.GetMetadata().GetRelations()[tuplesetRelation] + if !ok || len(relationMetadata.GetDirectlyRelatedUserTypes()) > 0 { + return nil } -} -func (c *ErrorCollector) RaiseInvalidWildcardUsage(typeName, relationName, parentTypeName, reason string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Invalid wildcard usage for type '%s' in relation '%s' of type '%s': %s", - typeName, relationName, parentTypeName, reason) - c.addError(message, InvalidWildcardError, typeName, lineIndex, meta, nil) -} + file, module := typeMeta(typeDef) + line := src.relationLine(relationName, -1) -func (c *ErrorCollector) RaiseTuplesetNotDirect(tuplesetRelation, typeName, parentRelation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Tupleset relation '%s' on type '%s' must allow direct assignment (used in relation '%s')", - tuplesetRelation, typeName, parentRelation) - c.addError(message, TuplesetNotDirect, tuplesetRelation, lineIndex, meta, nil) + return tuplesetNotDirect(tuplesetRelation, typeName, relationName).at(src, line).in(file, module) } diff --git a/pkg/go/validation/yaml_integration_test.go b/pkg/go/validation/yaml_integration_test.go index 81756e27..c712d0ab 100644 --- a/pkg/go/validation/yaml_integration_test.go +++ b/pkg/go/validation/yaml_integration_test.go @@ -4,363 +4,209 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestYAMLTestRunner_Basic tests the basic functionality of the YAML test runner -func TestYAMLTestRunner_Basic(t *testing.T) { - // Get the path to test data relative to the current working directory during tests - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Get available test suites", func(t *testing.T) { - suites, err := runner.GetAvailableTestSuites() - if err != nil { - t.Skipf("Could not access YAML test files (path: %s): %v", testDataPath, err) - return - } - - require.NoError(t, err) - assert.Greater(t, len(suites), 0, "Should find YAML test files") - - // Check for expected YAML files - expectedFiles := []string{ - "dsl-semantic-validation-cases.yaml", - "dsl-syntax-validation-cases.yaml", - "json-validation-cases.yaml", - } - - for _, expectedFile := range expectedFiles { - found := false - for _, suite := range suites { - if suite == expectedFile { - found = true - break - } - } - if found { - t.Logf("✅ Found expected test file: %s", expectedFile) - } else { - t.Logf("⚠️ Expected test file not found: %s", expectedFile) - } - } - }) - - t.Run("Load semantic validation test suite", func(t *testing.T) { - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - t.Skipf("Could not load semantic validation test suite: %v", err) - return - } - - require.NoError(t, err) - require.NotNil(t, suite) - assert.Greater(t, len(suite.TestCases), 0, "Should have test cases") - - t.Logf("📊 Loaded %d semantic validation test cases", len(suite.TestCases)) - - // Check first few test cases - if len(suite.TestCases) > 0 { - firstTest := suite.TestCases[0] - t.Logf("First test case: %s", firstTest.Name) - assert.NotEmpty(t, firstTest.DSL, "Test case should have DSL content") - } - }) -} +// corpusDir is the shared test data directory, which sits at the repository root and +// is read by the Go, JS and Java implementations alike. +var corpusDir = filepath.Join("..", "..", "..", "tests", "data") + +// TestSemanticValidationCorpus runs every case in the shared semantic validation +// corpus. +// +// A corpus that cannot be loaded fails the test rather than skipping it: a corpus that +// silently does not run looks exactly like one that passes. The cases the corpus itself +// marks skip are skipped, so they stay visible in the output. +func TestSemanticValidationCorpus(t *testing.T) { + t.Parallel() + + runner := NewYAMLTestRunner(corpusDir) -// TestYAMLTestRunner_SemanticValidation tests running semantic validation cases -func TestYAMLTestRunner_SemanticValidation(t *testing.T) { - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - t.Skipf("Could not load semantic validation test suite: %v", err) - return - } - - t.Run("Run sample semantic validation tests", func(t *testing.T) { - // Run first few test cases to validate framework - maxTests := 5 - if len(suite.TestCases) < maxTests { - maxTests = len(suite.TestCases) - } - - passedTests := 0 - failedTests := 0 - skippedTests := 0 - - for i := 0; i < maxTests; i++ { - testCase := suite.TestCases[i] - t.Logf("Running test case %d: %s", i+1, testCase.Name) - - result, err := runner.RunTestCase(testCase) - require.NoError(t, err, "Should not error when running test case") - - if result != nil { - switch result.Status { - case "PASS": - passedTests++ - t.Logf(" ✅ PASS: %s", result.Message) - case "FAIL": - failedTests++ - t.Logf(" ❌ FAIL: %s", result.Message) - for _, detail := range result.ErrorDetails { - t.Logf(" • %s", detail) - } - case "SKIPPED": - skippedTests++ - t.Logf(" ⏭️ SKIP: %s", result.Message) - default: - t.Logf(" ❓ %s: %s", result.Status, result.Message) - } - } - } - - t.Logf("📊 Sample Test Results: %d passed, %d failed, %d skipped", passedTests, failedTests, skippedTests) - }) -} + require.NoError(t, err) + require.NotEmpty(t, suite.TestCases, "corpus loaded no cases") -// TestYAMLTestRunner_ComprehensiveValidation runs comprehensive validation against YAML test cases -func TestYAMLTestRunner_ComprehensiveValidation(t *testing.T) { - if testing.Short() { - t.Skip("Skipping comprehensive YAML validation tests in short mode") - } - - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Run all semantic validation test cases", func(t *testing.T) { - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - t.Skipf("Could not load semantic validation test suite: %v", err) - return - } - - passedTests := 0 - failedTests := 0 - skippedTests := 0 - errorTests := 0 - - // Run all test cases - for i, testCase := range suite.TestCases { - if testing.Verbose() { - t.Logf("Running test case %d/%d: %s", i+1, len(suite.TestCases), testCase.Name) - } - - result, err := runner.RunTestCase(testCase) - if err != nil { - t.Errorf("Error running test case %s: %v", testCase.Name, err) - errorTests++ - continue + for _, testCase := range suite.TestCases { + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + + result := runner.RunTestCase(testCase) + if result.Status == corpusSkipped { + t.Skip("marked skip in the corpus") } - - switch result.Status { - case "PASS": - passedTests++ - case "FAIL": - failedTests++ - t.Errorf("❌ FAIL: %s: %s", testCase.Name, result.Message) - for _, detail := range result.ErrorDetails { - t.Errorf(" • %s", detail) - } - case "SKIPPED": - skippedTests++ - case "ERROR": - errorTests++ - t.Errorf("💥 ERROR: %s: %s", testCase.Name, result.Message) + + for _, problem := range result.Problems { + t.Error(problem) } - } - - totalTests := len(suite.TestCases) - passRate := float64(passedTests) / float64(totalTests) * 100.0 - - t.Logf("📊 Comprehensive Semantic Validation Results:") - t.Logf(" Total Tests: %d", totalTests) - t.Logf(" Passed: %d (%.1f%%)", passedTests, passRate) - t.Logf(" Failed: %d", failedTests) - t.Logf(" Skipped: %d", skippedTests) - t.Logf(" Errors: %d", errorTests) - // Every non-skipped case must match the JS reference. Individual FAIL and - // ERROR cases already call t.Errorf above; this guards against a case - // silently vanishing (e.g. all cases skipped or none loaded). - require.Positive(t, passedTests, "expected passing semantic validation cases") - }) + // The problems above are the readable failure; this catches a status that + // reported none. + require.Equal(t, corpusPass, result.Status) + }) + } } -// TestYAMLTestRunner_AllSuites runs tests against all available YAML test suites -func TestYAMLTestRunner_AllSuites(t *testing.T) { - if testing.Short() { - t.Skip("Skipping comprehensive all-suite YAML tests in short mode") +// TestCompareWithCorpus covers the comparison itself. It is what decides whether the +// corpus passes, so a change that loosened it — comparing a prefix of the message, +// treating a finding with no position as a match — would take the whole corpus green +// with it. +func TestCompareWithCorpus(t *testing.T) { + t.Parallel() + + expected := YAMLExpectedError{ + Message: "the relation `viewer` does not exist.", + Line: &YAMLRange{Start: 4, End: 4}, + Column: &YAMLRange{Start: 12, End: 18}, + Metadata: YAMLErrorMetadata{ + Symbol: "viewer", + ErrorType: string(MissingDefinition), + }, } - - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Run all available test suites", func(t *testing.T) { - results, err := runner.RunAllTestSuites() - if err != nil { - t.Skipf("Could not run all test suites: %v", err) - return - } - - report := runner.GenerateTestReport(results) - - // Print report to test output - t.Logf("📊 YAML Test Integration Report") - t.Logf("================================") - t.Logf("Total Tests: %d", report.Summary["TOTAL"]) - t.Logf("Passed: %d", report.Summary["PASS"]) - t.Logf("Failed: %d", report.Summary["FAIL"]) - t.Logf("Skipped: %d", report.Summary["SKIPPED"]) - t.Logf("Errors: %d", report.Summary["ERROR"]) - t.Logf("Pass Rate: %.1f%%", report.PassRate) - - // Detailed suite breakdown - for suiteName, suiteResults := range results { - passed := 0 - failed := 0 - skipped := 0 - errors := 0 - - for _, result := range suiteResults { - switch result.Status { - case "PASS": - passed++ - case "FAIL": - failed++ - case "SKIPPED": - skipped++ - case "ERROR": - errors++ - } - } - - t.Logf("📋 Suite: %s - Tests: %d, Passed: %d, Failed: %d, Skipped: %d, Errors: %d", - suiteName, len(suiteResults), passed, failed, skipped, errors) - } - - // Validate that we have a working test framework - assert.Greater(t, report.Summary["TOTAL"], 0, "Should have run some tests") - - // Log insights about current validation system state - if report.Summary["PASS"] > 0 { - t.Logf("✅ Validation system working - %d tests passed", report.Summary["PASS"]) - } - - if report.Summary["FAIL"] > 0 { - t.Logf("🔧 Validation improvements needed - %d tests failed", report.Summary["FAIL"]) - } - - if report.PassRate > 50 { - t.Logf("🎯 Good validation coverage - %.1f%% pass rate", report.PassRate) - } else if report.PassRate > 0 { - t.Logf("⚠️ Moderate validation coverage - %.1f%% pass rate", report.PassRate) - } - }) -} -// TestYAMLTestRunner_SpecificScenarios tests specific validation scenarios -func TestYAMLTestRunner_SpecificScenarios(t *testing.T) { - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Test error matching functionality", func(t *testing.T) { - // Create a test case to validate our error matching logic - testCase := YAMLTestCase{ - Name: "test error matching", - DSL: ` -model - schema 1.1 -type user -type document - relations - define viewer: nonexistent -`, - ExpectedErrors: []YAMLExpectedError{ - { - Message: "relation `nonexistent` does not exist", - Metadata: YAMLErrorMetadata{ - ErrorType: "undefined-relation", - }, - }, + finding := func(mutate func(*Finding)) *Finding { + found := &Finding{ + Message: "the relation `viewer` does not exist.", + Line: &Range{Start: 4, End: 4}, + Column: &Range{Start: 12, End: 18}, + Metadata: Metadata{ + Symbol: "viewer", + Kind: MissingDefinition, }, } - - result, err := runner.RunTestCase(testCase) - require.NoError(t, err) - require.NotNil(t, result) - - t.Logf("Test case result: %s - %s", result.Status, result.Message) - - if result.Status == "FAIL" { - for _, detail := range result.ErrorDetails { - t.Logf(" Error detail: %s", detail) - } + if mutate != nil { + mutate(found) } - - // For validation - we expect this to work but may need refinement - if len(result.ActualErrors) > 0 { - t.Logf("✅ Validation system detected %d errors", len(result.ActualErrors)) - for _, err := range result.ActualErrors { - t.Logf(" - %s (Type: %s)", err.Message, err.Metadata.ErrorType) - } - } - }) - - t.Run("Test schema version validation", func(t *testing.T) { - testCase := YAMLTestCase{ - Name: "invalid schema version", - DSL: ` -model - schema 0.9 -type user -`, - ExpectedErrors: []YAMLExpectedError{ - { - Message: "invalid schema 0.9", - Metadata: YAMLErrorMetadata{ - ErrorType: "invalid-schema", - }, - }, - }, - } - - result, err := runner.RunTestCase(testCase) - require.NoError(t, err) - require.NotNil(t, result) - - t.Logf("Schema validation result: %s - %s", result.Status, result.Message) - }) -} -// BenchmarkYAMLTestRunner benchmarks the YAML test runner performance -func BenchmarkYAMLTestRunner(b *testing.B) { - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - // Load a test suite for benchmarking - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - b.Skipf("Could not load test suite for benchmarking: %v", err) - return + return found } - - if len(suite.TestCases) == 0 { - b.Skip("No test cases available for benchmarking") - return + + tests := []struct { + name string + expected []YAMLExpectedError + findings []*Finding + problems int + }{ + { + name: "match", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(nil)}, + }, + { + name: "no errors expected and none found", + expected: nil, + findings: nil, + }, + { + // A message that only starts with the corpus's is a different message, so + // it satisfies nothing and is reported twice: the expectation went + // unmatched and the finding was not expected. + name: "message is longer than the corpus states", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Message += " Did you mean `view`?" + })}, + problems: 2, + }, + { + name: "wrong line", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Line = &Range{Start: 5, End: 5} + })}, + problems: 1, + }, + { + name: "line end differs", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Line = &Range{Start: 4, End: 6} + })}, + problems: 1, + }, + { + name: "no position at all", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Line, f.Column = nil, nil + })}, + problems: 1, + }, + { + name: "wrong column", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Column = &Range{Start: 12, End: 17} + })}, + problems: 1, + }, + { + name: "wrong symbol", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Metadata.Symbol = "editor" + })}, + problems: 1, + }, + { + name: "wrong error type", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Metadata.Kind = UndefinedRelation + })}, + problems: 1, + }, + { + name: "one finding does not satisfy two expectations", + expected: []YAMLExpectedError{expected, expected}, + findings: []*Finding{finding(nil)}, + problems: 1, + }, + { + name: "finding the corpus does not expect", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(nil), finding(func(f *Finding) { + f.Message = "the relation `editor` does not exist." + })}, + problems: 1, + }, + { + name: "position the corpus leaves out is not compared", + expected: []YAMLExpectedError{{Message: expected.Message}}, + findings: []*Finding{finding(func(f *Finding) { + f.Line, f.Column = nil, nil + })}, + }, } - - // Use first test case for benchmarking - testCase := suite.TestCases[0] - - b.ResetTimer() - b.Run("RunSingleTestCase", func(b *testing.B) { - for i := 0; i < b.N; i++ { - _, _ = runner.RunTestCase(testCase) - } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + result := compareWithCorpus(test.expected, test.findings) + + require.Len(t, result.Problems, test.problems, "problems: %v", result.Problems) + + if test.problems == 0 { + require.Equal(t, corpusPass, result.Status) + } else { + require.Equal(t, corpusFail, result.Status) + } + }) + } +} + +// TestRunTestCaseUnparsableDSL covers the one outcome the corpus cases cannot reach: a +// case whose DSL the transformer rejects tested nothing about validation, so it is +// reported as an error rather than passing for lack of findings. +func TestRunTestCaseUnparsableDSL(t *testing.T) { + t.Parallel() + + result := NewYAMLTestRunner(corpusDir).RunTestCase(YAMLTestCase{ + Name: "not a model", + DSL: "type document\n relations\n", }) + + require.Equal(t, corpusError, result.Status) + require.Len(t, result.Problems, 1) + require.Contains(t, result.Problems[0], "DSL does not parse") } diff --git a/pkg/go/validation/yaml_test_integration_test.go b/pkg/go/validation/yaml_test_integration_test.go index e04b3cb1..7964b367 100644 --- a/pkg/go/validation/yaml_test_integration_test.go +++ b/pkg/go/validation/yaml_test_integration_test.go @@ -4,61 +4,71 @@ import ( "fmt" "os" "path/filepath" - "strings" "gopkg.in/yaml.v3" - openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/openfga/language/pkg/go/transformer" ) -// YAMLTestCase represents a single test case from the YAML validation test files -type YAMLTestCase struct { - Name string `yaml:"name"` - DSL string `yaml:"dsl"` - Skip bool `yaml:"skip,omitempty"` - ExpectedErrors []YAMLExpectedError `yaml:"expected_errors,omitempty"` - Metadata map[string]interface{} `yaml:"metadata,omitempty"` +// Outcomes of a corpus case. ERROR is a case whose DSL no longer parses, which is +// distinct from FAIL: nothing about validation was tested. +const ( + corpusPass = "PASS" + corpusFail = "FAIL" + corpusSkipped = "SKIPPED" + corpusError = "ERROR" +) + +// findingsOf recovers the findings behind a validation error; nil in, none out. +func findingsOf(err error) []*Finding { + return ExtractAllAs[*Finding](err) } -// YAMLExpectedError represents an expected validation error from YAML test files -type YAMLExpectedError struct { - Message string `yaml:"msg"` - Line YAMLLineRange `yaml:"line,omitempty"` - Column YAMLColumnRange `yaml:"column,omitempty"` - Metadata YAMLErrorMetadata `yaml:"metadata,omitempty"` +// YAMLTestCase is one case from the shared validation corpus under tests/data. +type YAMLTestCase struct { + Name string `yaml:"name"` + DSL string `yaml:"dsl"` + Skip bool `yaml:"skip,omitempty"` + ExpectedErrors []YAMLExpectedError `yaml:"expected_errors,omitempty"` } -// YAMLLineRange represents line start and end positions -type YAMLLineRange struct { - Start int `yaml:"start"` - End int `yaml:"end"` +// YAMLExpectedError is one error a corpus case expects. +// +// Line and Column are pointers so a case that states no position is told apart from +// one that states position 0. The corpus counts lines from zero, so the zero value is +// a real position and a value type would make the two indistinguishable. +type YAMLExpectedError struct { + Message string `yaml:"msg"` + Line *YAMLRange `yaml:"line,omitempty"` + Column *YAMLRange `yaml:"column,omitempty"` + Metadata YAMLErrorMetadata `yaml:"metadata,omitempty"` } -// YAMLColumnRange represents column start and end positions -type YAMLColumnRange struct { +// YAMLRange is a start and end position, used for both the line and the column. +type YAMLRange struct { Start int `yaml:"start"` End int `yaml:"end"` } -// YAMLErrorMetadata represents error metadata from YAML test files +// YAMLErrorMetadata is the metadata a corpus case pins: the offending symbol and the +// error type. type YAMLErrorMetadata struct { Symbol string `yaml:"symbol,omitempty"` ErrorType string `yaml:"errorType,omitempty"` } -// YAMLTestSuite represents a collection of YAML test cases +// YAMLTestSuite is a corpus file's cases. type YAMLTestSuite struct { TestCases []YAMLTestCase FilePath string } -// YAMLTestRunner handles running YAML-based validation tests +// YAMLTestRunner loads corpus files from testDataPath and runs their cases. type YAMLTestRunner struct { testDataPath string suites map[string]*YAMLTestSuite } -// NewYAMLTestRunner creates a new YAML test runner func NewYAMLTestRunner(testDataPath string) *YAMLTestRunner { return &YAMLTestRunner{ testDataPath: testDataPath, @@ -66,322 +76,191 @@ func NewYAMLTestRunner(testDataPath string) *YAMLTestRunner { } } -// LoadTestSuite loads a YAML test suite from file +// LoadTestSuite reads and parses a corpus file, caching it by name. func (runner *YAMLTestRunner) LoadTestSuite(filename string) (*YAMLTestSuite, error) { - filePath := filepath.Join(runner.testDataPath, filename) - - // Check if already loaded if suite, exists := runner.suites[filename]; exists { return suite, nil } - - // Read YAML file + + filePath := filepath.Join(runner.testDataPath, filename) + data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read YAML file %s: %w", filePath, err) } - - // Parse YAML + var testCases []YAMLTestCase if err := yaml.Unmarshal(data, &testCases); err != nil { return nil, fmt.Errorf("failed to parse YAML file %s: %w", filePath, err) } - - suite := &YAMLTestSuite{ - TestCases: testCases, - FilePath: filePath, - } - + + suite := &YAMLTestSuite{TestCases: testCases, FilePath: filePath} runner.suites[filename] = suite + return suite, nil } -// GetAvailableTestSuites returns all available YAML test suite files -func (runner *YAMLTestRunner) GetAvailableTestSuites() ([]string, error) { - files, err := os.ReadDir(runner.testDataPath) - if err != nil { - return nil, fmt.Errorf("failed to read test data directory: %w", err) - } - - var yamlFiles []string - for _, file := range files { - if !file.IsDir() && (strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml")) { - yamlFiles = append(yamlFiles, file.Name()) - } - } - - return yamlFiles, nil +// YAMLTestResult is the outcome of one corpus case. +type YAMLTestResult struct { + Status string + + // Problems lists every divergence from the case, one per line, and is empty for + // a case that passed. + Problems []string } -// RunTestCase runs a single YAML test case and compares results -func (runner *YAMLTestRunner) RunTestCase(testCase YAMLTestCase) (*YAMLTestResult, error) { +// RunTestCase validates a case's DSL and compares the findings with what the case +// expects. +func (runner *YAMLTestRunner) RunTestCase(testCase YAMLTestCase) *YAMLTestResult { if testCase.Skip { - return &YAMLTestResult{ - TestCase: testCase, - Status: "SKIPPED", - Message: "Test case marked as skip in YAML", - }, nil + return &YAMLTestResult{Status: corpusSkipped} } - - // Parse DSL to create authorization model - model, err := runner.parseDSLToModel(testCase.DSL) + + model, err := transformer.TransformDSLToProto(testCase.DSL) if err != nil { return &YAMLTestResult{ - TestCase: testCase, - Status: "ERROR", - Message: fmt.Sprintf("Failed to parse DSL: %v", err), - }, nil + Status: corpusError, + Problems: []string{fmt.Sprintf("DSL does not parse: %v", err)}, + } } - - // Run validation - validationErrors := ValidateDSL(model, testCase.DSL, DefaultEngineOptions()) - - // Compare results - result := runner.compareResults(testCase, validationErrors) - return result, nil -} -// YAMLTestResult represents the result of running a YAML test case -type YAMLTestResult struct { - TestCase YAMLTestCase - Status string // "PASS", "FAIL", "SKIPPED", "ERROR" - Message string - ActualErrors []*ValidationError - ExpectedErrors []YAMLExpectedError - ErrorDetails []string + return compareWithCorpus(testCase.ExpectedErrors, findingsOf(ValidateDSL(model, testCase.DSL))) } -// compareResults compares actual validation results with expected results from YAML -func (runner *YAMLTestRunner) compareResults(testCase YAMLTestCase, validationErrors *ValidationErrors) *YAMLTestResult { - result := &YAMLTestResult{ - TestCase: testCase, - ActualErrors: validationErrors.GetErrors(), - ExpectedErrors: testCase.ExpectedErrors, - } - - actualCount := validationErrors.Count() - expectedCount := len(testCase.ExpectedErrors) - - // Check error count match - if actualCount != expectedCount { - result.Status = "FAIL" - result.Message = fmt.Sprintf("Error count mismatch: expected %d, got %d", expectedCount, actualCount) - result.ErrorDetails = append(result.ErrorDetails, result.Message) - } - - // If no errors expected and none found, test passes - if expectedCount == 0 && actualCount == 0 { - result.Status = "PASS" - result.Message = "No errors expected and none found" - return result - } - - // Compare individual errors - errorMatches := make([]bool, len(testCase.ExpectedErrors)) - for i, expectedError := range testCase.ExpectedErrors { - matched := false - for _, actualError := range validationErrors.GetErrors() { - if runner.errorsMatch(expectedError, actualError) { - matched = true +// compareWithCorpus pairs each expected error with a distinct finding, so a case +// expecting two errors is not satisfied by one finding that matches both. +func compareWithCorpus(expectedErrors []YAMLExpectedError, findings []*Finding) *YAMLTestResult { + result := &YAMLTestResult{} + claimed := make([]bool, len(findings)) + matched := make([]bool, len(expectedErrors)) + + // Whole matches are paired first. Pairing on the message alone up front would let + // one expectation take the finding that another one matches outright. + for i, expected := range expectedErrors { + for j, finding := range findings { + if !claimed[j] && describeMismatch(expected, finding) == "" { + claimed[j], matched[i] = true, true + break } } - errorMatches[i] = matched - if !matched { - detail := fmt.Sprintf("Expected error not found: %s", expectedError.Message) - result.ErrorDetails = append(result.ErrorDetails, detail) - } } - - // Check for unexpected errors - for _, actualError := range validationErrors.GetErrors() { - matched := false - for _, expectedError := range testCase.ExpectedErrors { - if runner.errorsMatch(expectedError, actualError) { - matched = true + + // What is left pairs on the message alone, so a finding that resolved to the wrong + // line is reported as that one field rather than as an expectation with nothing + // behind it plus an unexplained extra finding. + for i, expected := range expectedErrors { + if matched[i] { + continue + } + + paired := false + + for j, finding := range findings { + if !claimed[j] && finding.Message == expected.Message { + claimed[j], paired = true, true + result.Problems = append(result.Problems, + fmt.Sprintf("expected %s: %s", describeExpected(expected), describeMismatch(expected, finding))) + break } } - if !matched { - detail := fmt.Sprintf("Unexpected error found: %s", actualError.Message) - result.ErrorDetails = append(result.ErrorDetails, detail) + + if !paired { + result.Problems = append(result.Problems, + fmt.Sprintf("no finding matches expected %s", describeExpected(expected))) + } + } + + for j, finding := range findings { + if !claimed[j] { + result.Problems = append(result.Problems, fmt.Sprintf("unexpected finding %s", describeFinding(finding))) } } - - // Determine overall status - if len(result.ErrorDetails) == 0 { - result.Status = "PASS" - result.Message = fmt.Sprintf("All %d errors matched correctly", expectedCount) + + if len(result.Problems) > 0 { + result.Status = corpusFail } else { - result.Status = "FAIL" - result.Message = fmt.Sprintf("Found %d error mismatches", len(result.ErrorDetails)) + result.Status = corpusPass } - + return result } -// errorsMatch checks if an expected error matches an actual validation error -func (runner *YAMLTestRunner) errorsMatch(expected YAMLExpectedError, actual *ValidationError) bool { - // Check message content (allow partial matches for flexibility) - if !strings.Contains(strings.ToLower(actual.Message), strings.ToLower(expected.Message)) { - return false +// describeMismatch returns what keeps finding from satisfying expected, or "" when +// nothing does. +// +// The message has to be equal rather than merely contain the expected text: the corpus +// is the contract between the implementations, so a message that only starts with the +// reference's is a divergence, not a pass. +func describeMismatch(expected YAMLExpectedError, finding *Finding) string { + if finding.Message != expected.Message { + return fmt.Sprintf("message %q, want %q", finding.Message, expected.Message) } - - // Check error type if specified - if expected.Metadata.ErrorType != "" { - expectedType := ValidationErrorType(expected.Metadata.ErrorType) - if actual.Metadata.ErrorType != expectedType { - return false - } + + if expected.Metadata.ErrorType != "" && + string(finding.Metadata.Kind) != expected.Metadata.ErrorType { + return fmt.Sprintf("errorType %q, want %q", finding.Metadata.Kind, expected.Metadata.ErrorType) } - - // Check line numbers if specified - if expected.Line.Start > 0 && actual.Line != nil { - if actual.Line.Start != expected.Line.Start { - return false - } + + if expected.Metadata.Symbol != "" && finding.Metadata.Symbol != expected.Metadata.Symbol { + return fmt.Sprintf("symbol %q, want %q", finding.Metadata.Symbol, expected.Metadata.Symbol) } - - // Check column numbers if specified - if expected.Column.Start > 0 && actual.Column != nil { - if actual.Column.Start != expected.Column.Start { - return false - } + + // A position the corpus states has to be reached, both ends of it. A finding + // with no position at all must not satisfy an expectation that states one: + // resolving to nowhere in the source is a failure mode of the line searches, + // not a match. + if problem := describeRangeMismatch("line", expected.Line, finding.Line); problem != "" { + return problem } - - return true + + return describeRangeMismatch("column", expected.Column, finding.Column) } -// parseDSLToModel converts DSL content to an AuthorizationModel -func (runner *YAMLTestRunner) parseDSLToModel(dsl string) (*openfgav1.AuthorizationModel, error) { - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - return nil, fmt.Errorf("failed to parse DSL: %w", err) - } - if model == nil { - return nil, fmt.Errorf("failed to parse DSL") +func describeRangeMismatch(name string, expected *YAMLRange, actual *Range) string { + if expected == nil { + return "" } - return model, nil -} -// RunAllTestSuites runs all available YAML test suites -func (runner *YAMLTestRunner) RunAllTestSuites() (map[string][]*YAMLTestResult, error) { - suiteFiles, err := runner.GetAvailableTestSuites() - if err != nil { - return nil, err + if actual == nil { + return "no " + name } - - results := make(map[string][]*YAMLTestResult) - - for _, suiteFile := range suiteFiles { - suite, err := runner.LoadTestSuite(suiteFile) - if err != nil { - return nil, fmt.Errorf("failed to load test suite %s: %w", suiteFile, err) - } - - var suiteResults []*YAMLTestResult - for _, testCase := range suite.TestCases { - result, err := runner.RunTestCase(testCase) - if err != nil { - result = &YAMLTestResult{ - TestCase: testCase, - Status: "ERROR", - Message: err.Error(), - } - } - suiteResults = append(suiteResults, result) - } - - results[suiteFile] = suiteResults + + if actual.Start != expected.Start || actual.End != expected.End { + return fmt.Sprintf("%s %d-%d, want %d-%d", name, actual.Start, actual.End, expected.Start, expected.End) } - - return results, nil + + return "" } -// GenerateTestReport generates a comprehensive test report -func (runner *YAMLTestRunner) GenerateTestReport(results map[string][]*YAMLTestResult) *YAMLTestReport { - report := &YAMLTestReport{ - SuiteResults: results, - Summary: make(map[string]int), - } - - totalTests := 0 - for _, suiteResults := range results { - for _, result := range suiteResults { - totalTests++ - report.Summary[result.Status]++ - } +func describeExpected(expected YAMLExpectedError) string { + return fmt.Sprintf("%q [%s]%s", expected.Message, expected.Metadata.ErrorType, + describePosition(rangeOf(expected.Line), rangeOf(expected.Column))) +} + +func describeFinding(finding *Finding) string { + return fmt.Sprintf("%q [%s]%s", finding.Message, finding.Metadata.Kind, + describePosition(finding.Line, finding.Column)) +} + +func describePosition(line, column *Range) string { + described := "" + if line != nil { + described += fmt.Sprintf(" line %d-%d", line.Start, line.End) } - - report.Summary["TOTAL"] = totalTests - - // Calculate pass rate - if totalTests > 0 { - passCount := report.Summary["PASS"] - report.PassRate = float64(passCount) / float64(totalTests) * 100.0 + + if column != nil { + described += fmt.Sprintf(" column %d-%d", column.Start, column.End) } - - return report -} -// YAMLTestReport represents a comprehensive test report -type YAMLTestReport struct { - SuiteResults map[string][]*YAMLTestResult - Summary map[string]int - PassRate float64 + return described } -// PrintReport prints a formatted test report -func (report *YAMLTestReport) PrintReport() { - fmt.Printf("YAML Test Integration Report\n") - fmt.Printf("================================\n\n") - - fmt.Printf("Summary:\n") - fmt.Printf(" Total Tests: %d\n", report.Summary["TOTAL"]) - fmt.Printf(" Passed: %d\n", report.Summary["PASS"]) - fmt.Printf(" Failed: %d\n", report.Summary["FAIL"]) - fmt.Printf(" Skipped: %d\n", report.Summary["SKIPPED"]) - fmt.Printf(" Errors: %d\n", report.Summary["ERROR"]) - fmt.Printf(" Pass Rate: %.1f%%\n\n", report.PassRate) - - // Print detailed results for each suite - for suiteName, results := range report.SuiteResults { - fmt.Printf("Test Suite: %s\n", suiteName) - fmt.Printf(" Tests: %d\n", len(results)) - - passed := 0 - failed := 0 - skipped := 0 - errors := 0 - - for _, result := range results { - switch result.Status { - case "PASS": - passed++ - case "FAIL": - failed++ - case "SKIPPED": - skipped++ - case "ERROR": - errors++ - } - } - - fmt.Printf(" Passed: %d, Failed: %d, Skipped: %d, Errors: %d\n", passed, failed, skipped, errors) - - // Show failed tests - if failed > 0 || errors > 0 { - fmt.Printf(" Failed/Error Tests:\n") - for _, result := range results { - if result.Status == "FAIL" || result.Status == "ERROR" { - fmt.Printf(" - %s: %s\n", result.TestCase.Name, result.Message) - for _, detail := range result.ErrorDetails { - fmt.Printf(" • %s\n", detail) - } - } - } - } - - fmt.Printf("\n") +func rangeOf(yamlRange *YAMLRange) *Range { + if yamlRange == nil { + return nil } + + return &Range{Start: yamlRange.Start, End: yamlRange.End} } diff --git a/pkg/js/package-lock.json b/pkg/js/package-lock.json index 574846c7..00279dcf 100644 --- a/pkg/js/package-lock.json +++ b/pkg/js/package-lock.json @@ -629,9 +629,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -720,9 +720,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2537,16 +2537,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/brace-expansion/node_modules/balanced-match": { @@ -3557,9 +3557,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3648,9 +3648,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3858,9 +3858,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5729,9 +5729,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7337,9 +7337,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/tests/data/dsl-semantic-validation-cases.yaml b/tests/data/dsl-semantic-validation-cases.yaml index d788997a..b53b5431 100644 --- a/tests/data/dsl-semantic-validation-cases.yaml +++ b/tests/data/dsl-semantic-validation-cases.yaml @@ -1,4 +1,12 @@ --- +# Semantic validation cases shared by pkg/go, pkg/js and pkg/java, so a case may only +# use the fields all three read: name, dsl, skip, expected_errors, and within each +# expected error msg, line, column and metadata (symbol, errorType). +# +# A field only one language has does not belong here. pkg/java deserialises this file +# with a plain YAMLMapper onto case classes with no @JsonIgnoreProperties, so an +# unrecognised key fails its build, and pkg/js asserts each expected error with +# toMatchObject, which fails on an expected key the error object does not carry. - name: model 1.1 diff in exclusion not valid and spaces are reflected correctly in error messages dsl: | model