From 4245db6eecd26ffd232f6425d7efb4880b97a1ca Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Tue, 18 Aug 2026 20:35:02 +0530 Subject: [PATCH 1/8] feat(pkg/go)!: classify validation findings by severity, category and cause Each finding carries a severity, the part of the model it is about, and a sentinel wrapped in a scoped error type, so callers match with errors.Is and errors.As rather than on message text. The shared corpora under tests/data are the contract this is checked against. The runner compares the message exactly, along with the symbol, the error type and both ends of the reported position, and pairs each expected error with a distinct finding. Go now also reads json-validation-cases.yaml, which the JS and Java validators already consume. That runner found the multiple-modules-in-file rule reporting a file's modules in wording of its own, in name order, and without the modules a relation declares. It now collects them as the reference does and reports them in the order the model declares them. Findings that took their order from ranging a map are ordered too, so validating one model twice reports the same list. A malformed condition name is scoped to the condition through RaiseInvalidConditionName: metadata.condition carries the name, metadata.type stays empty, and errors.As yields *ErrCondition. Severity and ModelErrorKind serialise as their wire name through MarshalText and UnmarshalText. A map key needs those methods specifically, since encoding/json consults neither String nor MarshalJSON for a key, and ValidationSummary.FindingsBySeverity is keyed by Severity. BREAKING CHANGE: the validation entry points return error instead of *ValidationErrors, and LineRange/ColumnRange are replaced by a single Range. ErrorCollector.GetErrors is now AllFindings. RaiseInvalidRelationError no longer takes validRelations, RaiseReservedRelationName takes the enclosing type, and SemanticValidator.GetRelationNames is gone. ValidateMultipleModulesInFile and ValidateBasicModelStructure take []FileInfo in place of map[string]map[string]bool. The multiple-modules message text now matches the other SDKs, and a file whose only extra module is declared by a relation now fails validation. --- docs/validation/model/README.md | 10 +- .../validation/model/TROUBLESHOOTING_GUIDE.md | 4 +- .../model/invalid-schema-version.md | 125 +--- docs/validation/model/invalid-schema.md | 131 +---- docs/validation/model/invalid-syntax.md | 2 +- .../model/schema-version-required.md | 2 +- .../model/schema-version-unsupported.md | 4 +- pkg/go/errors/doc.go | 13 + pkg/go/errors/example_test.go | 55 ++ pkg/go/errors/model_error.go | 108 ++++ pkg/go/errors/model_error_kind.go | 100 ++++ pkg/go/errors/model_error_test.go | 412 ++++++++++++++ pkg/go/errors/sentinels.go | 99 ++++ pkg/go/errors/severity.go | 100 ++++ .../complex_operation_validation.go | 9 +- pkg/go/validation/condition_validation.go | 20 +- .../validation/condition_validation_test.go | 64 +-- pkg/go/validation/context.go | 2 +- pkg/go/validation/criticality_test.go | 168 ++++++ pkg/go/validation/cycle_detection.go | 16 +- .../validation/cycle_detection_stress_test.go | 8 +- pkg/go/validation/cycle_detection_test.go | 8 +- pkg/go/validation/duplicate_detection.go | 8 +- pkg/go/validation/duplicate_detection_test.go | 14 +- pkg/go/validation/error_collector.go | 317 +++++++++-- pkg/go/validation/error_collector_test.go | 77 ++- pkg/go/validation/error_info.go | 243 ++++++++ .../validation/error_info_integration_test.go | 335 +++++++++++ pkg/go/validation/error_info_test.go | 314 +++++++++++ pkg/go/validation/errors.go | 197 ++++++- pkg/go/validation/errors_test.go | 155 ++++- pkg/go/validation/json_corpus_test.go | 64 +++ pkg/go/validation/keywords_test.go | 8 +- pkg/go/validation/multi_file_validation.go | 178 ++++-- .../validation/multi_file_validation_test.go | 288 ++++++++++ pkg/go/validation/name_validation.go | 46 +- pkg/go/validation/name_validation_test.go | 12 +- pkg/go/validation/schema_validation.go | 31 +- pkg/go/validation/schema_validation_test.go | 95 ++-- pkg/go/validation/semantic_validation.go | 35 +- pkg/go/validation/semantic_validation_test.go | 40 +- pkg/go/validation/severity_fixtures_test.go | 205 +++++++ pkg/go/validation/severity_predicates_test.go | 344 +++++++++++ .../testdata/severity-category-cases.yaml | 137 +++++ pkg/go/validation/validation_engine.go | 147 ++--- pkg/go/validation/validation_engine_test.go | 240 ++++++-- pkg/go/validation/wildcard_validation.go | 26 +- pkg/go/validation/yaml_integration_test.go | 532 +++++++----------- .../validation/yaml_test_integration_test.go | 455 ++++++--------- pkg/js/package-lock.json | 50 +- tests/data/dsl-semantic-validation-cases.yaml | 12 + 51 files changed, 4711 insertions(+), 1354 deletions(-) create mode 100644 pkg/go/errors/doc.go create mode 100644 pkg/go/errors/example_test.go create mode 100644 pkg/go/errors/model_error.go create mode 100644 pkg/go/errors/model_error_kind.go create mode 100644 pkg/go/errors/model_error_test.go create mode 100644 pkg/go/errors/sentinels.go create mode 100644 pkg/go/errors/severity.go create mode 100644 pkg/go/validation/criticality_test.go create mode 100644 pkg/go/validation/error_info.go create mode 100644 pkg/go/validation/error_info_integration_test.go create mode 100644 pkg/go/validation/error_info_test.go create mode 100644 pkg/go/validation/json_corpus_test.go create mode 100644 pkg/go/validation/multi_file_validation_test.go create mode 100644 pkg/go/validation/severity_fixtures_test.go create mode 100644 pkg/go/validation/severity_predicates_test.go create mode 100644 pkg/go/validation/testdata/severity-category-cases.yaml 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/errors/doc.go b/pkg/go/errors/doc.go new file mode 100644 index 00000000..68c01f37 --- /dev/null +++ b/pkg/go/errors/doc.go @@ -0,0 +1,13 @@ +// Package errors holds the error types and sentinels that validation findings are +// built from, so callers match on values rather than on message text. +// +// A finding has two parts. The sentinel says what the problem is and is matched +// with errors.Is; see sentinels.go. The scope says which part of a model the +// problem is in, is the type the sentinel arrives wrapped in, and is matched with +// errors.As: ErrObjectType, ErrRelation, ErrRelationCondition, ErrCondition, or +// ErrModel when no single part is responsible. +// +// For a consumer that sees only serialised output, ModelErrorKind is the scope as +// a name and Severity is whether the finding blocks. Both reserve zero for "not +// set" and serialise as their name, so the names are API and the numbers are not. +package errors diff --git a/pkg/go/errors/example_test.go b/pkg/go/errors/example_test.go new file mode 100644 index 00000000..80ff7725 --- /dev/null +++ b/pkg/go/errors/example_test.go @@ -0,0 +1,55 @@ +package errors_test + +import ( + "errors" + "fmt" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// Example_reasonAndScope shows how a caller recovers the sentinel with errors.Is +// and the scope with errors.As. +func Example_reasonAndScope() { + // A validator raises the sentinel wrapped in the scope it was raised at. + err := error(&fgaerrors.ErrRelation{ + ObjectType: "document", + Relation: "viewer", + Cause: fgaerrors.ErrNoEntrypoints, + }) + + // The sentinel, through however many layers of wrapping. + if errors.Is(err, fgaerrors.ErrNoEntrypoints) { + fmt.Println("reason: no entrypoints") + } + + // The scope, with the fields that scope declares. + var relationErr *fgaerrors.ErrRelation + if errors.As(err, &relationErr) { + fmt.Printf("scope: %s#%s\n", relationErr.ObjectType, relationErr.Relation) + } + + // A different scope does not match. + var conditionErr *fgaerrors.ErrCondition + fmt.Println("condition scope:", errors.As(err, &conditionErr)) + + // Output: + // reason: no entrypoints + // scope: document#viewer + // condition scope: false +} + +// ExampleSeverity_Blocks shows that only one severity makes a model invalid. +func ExampleSeverity_Blocks() { + for _, severity := range []fgaerrors.Severity{ + fgaerrors.SeverityError, + fgaerrors.SeverityWarning, + fgaerrors.SeverityAdvisory, + } { + fmt.Printf("%s blocks: %t\n", severity, severity.Blocks()) + } + + // Output: + // error blocks: true + // warning blocks: false + // advisory blocks: false +} diff --git a/pkg/go/errors/model_error.go b/pkg/go/errors/model_error.go new file mode 100644 index 00000000..8bfe00b6 --- /dev/null +++ b/pkg/go/errors/model_error.go @@ -0,0 +1,108 @@ +package errors + +import "fmt" + +// The errors here name the part of a model a finding is about, and each declares +// only the fields its scope has: an ErrCondition has no relation, an +// ErrRelationCondition has all three. Cause holds the sentinel. +// +// var relationErr *errors.ErrRelation +// if errors.As(err, &relationErr) { +// fmt.Println(relationErr.ObjectType, relationErr.Relation) +// } +// +// They are named for the scope rather than the problem because many sentinels +// share a scope, and the shapes match the server's pkg/typesystem. Every exported +// error name here is Err-prefixed, types as well as sentinel values, so each type +// below opts out of errname's XxxError rule. + +// ErrObjectType is a finding about an object type as a whole. +// +//nolint:errname // Err-prefixed by convention here; see the naming note above +type ErrObjectType struct { + ObjectType string + Cause error +} + +func (e *ErrObjectType) Error() string { + return fmt.Sprintf("error in the definition of the object type '%s': %s", e.ObjectType, e.Cause) +} + +func (e *ErrObjectType) Unwrap() error { + return e.Cause +} + +// ErrRelation is a finding about a relation on an object type. +// +//nolint:errname // Err-prefixed by convention here; see the naming note above +type ErrRelation struct { + ObjectType string + Relation string + Cause error +} + +func (e *ErrRelation) Error() string { + if e.ObjectType == "" { + return fmt.Sprintf("error in the definition of relation '%s': %s", e.Relation, e.Cause) + } + + return fmt.Sprintf("error in the definition of relation '%s' of object type '%s': %s", + e.Relation, e.ObjectType, e.Cause) +} + +func (e *ErrRelation) Unwrap() error { + return e.Cause +} + +// ErrRelationCondition is a finding about a condition as applied to one relation, +// rather than about the condition's own definition. +// +//nolint:errname // Err-prefixed by convention here; see the naming note above +type ErrRelationCondition struct { + ObjectType string + Relation string + Condition string + Cause error +} + +func (e *ErrRelationCondition) Error() string { + return fmt.Sprintf("error in the definition of condition '%s' of relation '%s' in object type '%s': %s", + e.Condition, e.Relation, e.ObjectType, e.Cause) +} + +func (e *ErrRelationCondition) Unwrap() error { + return e.Cause +} + +// ErrCondition is a finding about a condition definition itself, independent of +// where it is applied. +// +//nolint:errname // Err-prefixed by convention here; see the naming note above +type ErrCondition struct { + Condition string + Cause error +} + +func (e *ErrCondition) Error() string { + return fmt.Sprintf("error in the definition of condition '%s': %s", e.Condition, e.Cause) +} + +func (e *ErrCondition) Unwrap() error { + return e.Cause +} + +// ErrModel is a finding about the model as a whole, which cannot be attributed +// to a single type, relation or condition. +// +//nolint:errname // Err-prefixed by convention here; see the naming note above +type ErrModel struct { + Cause error +} + +func (e *ErrModel) Error() string { + return fmt.Sprintf("error in authorization model: %s", e.Cause) +} + +func (e *ErrModel) Unwrap() error { + return e.Cause +} diff --git a/pkg/go/errors/model_error_kind.go b/pkg/go/errors/model_error_kind.go new file mode 100644 index 00000000..dab75f84 --- /dev/null +++ b/pkg/go/errors/model_error_kind.go @@ -0,0 +1,100 @@ +package errors + +import "fmt" + +// ModelErrorKind is the part of a model a finding is attached to: which kind of +// thing is wrong, as against the specific problem (the error code) and the +// identity of the thing (the finding's metadata). +// +// It serialises as its wire name, so the names below are API and the numbers are +// not. +type ModelErrorKind int + +// ModelErrorKindUnspecified is the zero value, reserved so that a finding which +// never set a category cannot pass for one that did. It has no wire name and +// omitempty drops it. +const ModelErrorKindUnspecified ModelErrorKind = 0 + +const ( + // ErrorKindObjectType is a finding about an object type as a whole. + ErrorKindObjectType ModelErrorKind = iota + 1 + + // ErrorKindRelation is a finding about a relation on an object type. + ErrorKindRelation + + // ErrorKindRelationCondition is a finding about a condition applied to a + // relation on an object type. + ErrorKindRelationCondition + + // ErrorKindCondition is a finding about a condition definition itself, + // independent of where it is applied. + ErrorKindCondition + + // ErrorKindInvalidModel is a finding about the model as a whole, which + // cannot be attributed to a single type, relation or condition. + ErrorKindInvalidModel +) + +// modelErrorKindNames maps each category to its wire name. A category missing +// from here fails to marshal, so a constant added without a name is caught. +var modelErrorKindNames = map[ModelErrorKind]string{ + ErrorKindObjectType: "object-type", + ErrorKindRelation: "relation", + ErrorKindRelationCondition: "relation-condition", + ErrorKindCondition: "condition", + ErrorKindInvalidModel: "invalid-model", +} + +// modelErrorKindValues is the reverse of modelErrorKindNames, built from it so +// the two cannot disagree. +var modelErrorKindValues = func() map[string]ModelErrorKind { + values := make(map[string]ModelErrorKind, len(modelErrorKindNames)) + for errorType, name := range modelErrorKindNames { + values[name] = errorType + } + + return values +}() + +// String returns the wire name, or a diagnostic form for a value with none. +func (m ModelErrorKind) String() string { + if name, ok := modelErrorKindNames[m]; ok { + return name + } + + if m == ModelErrorKindUnspecified { + return "" + } + + return fmt.Sprintf("ModelErrorKind(%d)", int(m)) +} + +// IsValid reports whether m is a declared category with a wire name. +func (m ModelErrorKind) IsValid() bool { + _, ok := modelErrorKindNames[m] + + return ok +} + +// MarshalText emits the wire name, so the JSON carries "object-type". +func (m ModelErrorKind) MarshalText() ([]byte, error) { + name, ok := modelErrorKindNames[m] + if !ok { + return nil, fmt.Errorf("%w: %d", ErrUnknownModelErrorKind, int(m)) + } + + return []byte(name), nil +} + +// UnmarshalText resolves a wire name back to its category, rejecting any name +// this package does not declare. +func (m *ModelErrorKind) UnmarshalText(text []byte) error { + errorType, ok := modelErrorKindValues[string(text)] + if !ok { + return fmt.Errorf("%w: %q", ErrUnknownModelErrorKind, text) + } + + *m = errorType + + return nil +} diff --git a/pkg/go/errors/model_error_test.go b/pkg/go/errors/model_error_test.go new file mode 100644 index 00000000..a3201936 --- /dev/null +++ b/pkg/go/errors/model_error_test.go @@ -0,0 +1,412 @@ +package errors_test + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// TestErrorsIsReachesCauseThroughScope checks errors.Is finds the sentinel through +// the scope type, so a caller branches on it without matching message text. +func TestErrorsIsReachesCauseThroughScope(t *testing.T) { + t.Parallel() + + err := &fgaerrors.ErrRelation{ + ObjectType: "document", + Relation: "viewer", + Cause: fgaerrors.ErrNoEntrypoints, + } + + require.ErrorIs(t, err, fgaerrors.ErrNoEntrypoints) + assert.NotErrorIs(t, err, fgaerrors.ErrReservedKeywords, + "must not match a sentinel it does not wrap") +} + +// TestErrorsIsWorksThroughFurtherWrapping checks the cause survives a caller +// adding its own context, which is the normal way these errors travel. +func TestErrorsIsWorksThroughFurtherWrapping(t *testing.T) { + t.Parallel() + + inner := &fgaerrors.ErrObjectType{ + ObjectType: "document", + Cause: fgaerrors.ErrDuplicateDefinition, + } + outer := fmt.Errorf("validating model: %w", inner) + + require.ErrorIs(t, outer, fgaerrors.ErrDuplicateDefinition) + + var objectTypeErr *fgaerrors.ErrObjectType + require.ErrorAs(t, outer, &objectTypeErr, "errors.As must find the scope through fmt.Errorf") + assert.Equal(t, "document", objectTypeErr.ObjectType) +} + +// TestErrorsAsExposesScope checks errors.As recovers each scope type with its +// fields intact, and that the message names them. +func TestErrorsAsExposesScope(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + err error + wantMessageSubstr string + wantCause error + wantScope func(t *testing.T, err error) + }{ + "object type": { + err: &fgaerrors.ErrObjectType{ + ObjectType: "document", + Cause: fgaerrors.ErrReservedKeywords, + }, + wantMessageSubstr: "the object type 'document'", + wantCause: fgaerrors.ErrReservedKeywords, + wantScope: func(t *testing.T, err error) { + t.Helper() + + var scoped *fgaerrors.ErrObjectType + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "document", scoped.ObjectType) + }, + }, + "relation": { + err: &fgaerrors.ErrRelation{ + ObjectType: "document", + Relation: "viewer", + Cause: fgaerrors.ErrNoEntrypoints, + }, + wantMessageSubstr: "relation 'viewer' of object type 'document'", + wantCause: fgaerrors.ErrNoEntrypoints, + wantScope: func(t *testing.T, err error) { + t.Helper() + + var scoped *fgaerrors.ErrRelation + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "document", scoped.ObjectType) + assert.Equal(t, "viewer", scoped.Relation) + }, + }, + "relation condition": { + err: &fgaerrors.ErrRelationCondition{ + ObjectType: "document", + Relation: "viewer", + Condition: "inRegion", + Cause: fgaerrors.ErrConditionUndefined, + }, + wantMessageSubstr: "condition 'inRegion' of relation 'viewer' in object type 'document'", + wantCause: fgaerrors.ErrConditionUndefined, + wantScope: func(t *testing.T, err error) { + t.Helper() + + var scoped *fgaerrors.ErrRelationCondition + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "document", scoped.ObjectType) + assert.Equal(t, "viewer", scoped.Relation) + assert.Equal(t, "inRegion", scoped.Condition) + }, + }, + "condition": { + err: &fgaerrors.ErrCondition{ + Condition: "inRegion", + Cause: fgaerrors.ErrConditionUnReferenced, + }, + wantMessageSubstr: "condition 'inRegion'", + wantCause: fgaerrors.ErrConditionUnReferenced, + wantScope: func(t *testing.T, err error) { + t.Helper() + + var scoped *fgaerrors.ErrCondition + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "inRegion", scoped.Condition) + }, + }, + "model": { + err: &fgaerrors.ErrModel{Cause: fgaerrors.ErrMultipleModulesInFile}, + wantMessageSubstr: "error in authorization model", + wantCause: fgaerrors.ErrMultipleModulesInFile, + wantScope: func(t *testing.T, err error) { + t.Helper() + + var scoped *fgaerrors.ErrModel + require.ErrorAs(t, err, &scoped) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + test.wantScope(t, test.err) + + assert.Contains(t, test.err.Error(), test.wantMessageSubstr) + + // The cause must survive into the message as well as into errors.Is; + // a message naming a relation but not the problem is not actionable. + assert.Contains(t, test.err.Error(), test.wantCause.Error()) + assert.ErrorIs(t, test.err, test.wantCause) + }) + } +} + +// TestScopedErrorsDoNotMatchEachOther checks the scopes are mutually exclusive: +// errors.As for one scope does not match a different one. +func TestScopedErrorsDoNotMatchEachOther(t *testing.T) { + t.Parallel() + + conditionErr := error(&fgaerrors.ErrCondition{ + Condition: "inRegion", + Cause: fgaerrors.ErrConditionUndefined, + }) + + var relationConditionErr *fgaerrors.ErrRelationCondition + assert.False(t, errors.As(conditionErr, &relationConditionErr), + "a condition definition finding must not pass for one about a relation's condition") + + var relationErr *fgaerrors.ErrRelation + assert.False(t, errors.As(conditionErr, &relationErr)) +} + +// TestRelationErrorWithoutObjectType covers relation-scoped raise sites that +// have no object type to attach: the message must not name an empty object +// type. +func TestRelationErrorWithoutObjectType(t *testing.T) { + t.Parallel() + + err := &fgaerrors.ErrRelation{ + Relation: "self", + Cause: fgaerrors.ErrReservedKeywords, + } + + assert.NotContains(t, err.Error(), "object type ''") + assert.Contains(t, err.Error(), "relation 'self'") + assert.Contains(t, err.Error(), fgaerrors.ErrReservedKeywords.Error()) +} + +func TestSeverityBlocks(t *testing.T) { + t.Parallel() + + assert.True(t, fgaerrors.SeverityError.Blocks(), "an error must fail validation") + assert.False(t, fgaerrors.SeverityWarning.Blocks(), "a warning must not fail a valid model") + assert.False(t, fgaerrors.SeverityAdvisory.Blocks(), "an advisory must not fail a valid model") + + // Only the two non-blocking severities answer false, so anything unrecognised + // blocks rather than letting an invalid model pass. + assert.True(t, fgaerrors.SeverityUnspecified.Blocks(), + "a finding that never set a severity must block") + assert.True(t, fgaerrors.Severity(99).Blocks(), + "a severity this package does not declare must block") +} + +// TestSeverityWireNames checks each severity marshals and unmarshals under its wire +// name. The names are API: the Go severity fixtures assert on them. +func TestSeverityWireNames(t *testing.T) { + t.Parallel() + + tests := map[fgaerrors.Severity]string{ + fgaerrors.SeverityError: `"error"`, + fgaerrors.SeverityWarning: `"warning"`, + fgaerrors.SeverityAdvisory: `"advisory"`, + } + + for severity, wantJSON := range tests { + t.Run(severity.String(), func(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(severity) + require.NoError(t, err) + assert.JSONEq(t, wantJSON, string(encoded)) + + var decoded fgaerrors.Severity + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, severity, decoded, "round trip must be lossless") + + assert.True(t, severity.IsValid()) + }) + } +} + +// TestSeverityRejectsUnknownValues covers both ends of the mapping: a number with +// no name must not reach a consumer as a bare integer, and a name this package +// does not declare must not decode to a severity. +func TestSeverityRejectsUnknownValues(t *testing.T) { + t.Parallel() + + _, err := json.Marshal(fgaerrors.Severity(99)) + require.ErrorIs(t, err, fgaerrors.ErrUnknownSeverity, + "an undeclared severity must fail to marshal rather than ship as 99") + + var decoded fgaerrors.Severity + require.ErrorIs(t, json.Unmarshal([]byte(`"critical"`), &decoded), + fgaerrors.ErrUnknownSeverity) + + assert.False(t, fgaerrors.Severity(99).IsValid()) + assert.False(t, fgaerrors.SeverityUnspecified.IsValid()) +} + +// TestSeverityUnspecifiedIsNotASeverity checks the zero value is not a severity. +// The constants count from one so a finding that never set a severity does not read +// as an error. +func TestSeverityUnspecifiedIsNotASeverity(t *testing.T) { + t.Parallel() + + assert.NotEqual(t, fgaerrors.SeverityUnspecified, fgaerrors.SeverityError, + "the first real severity must not be the zero value") + assert.Equal(t, 0, int(fgaerrors.SeverityUnspecified)) + assert.Empty(t, fgaerrors.SeverityUnspecified.String()) + + // omitempty drops it, so a finding with no severity ships without the field + // rather than claiming to be an error. + encoded, err := json.Marshal(struct { + Severity fgaerrors.Severity `json:"severity,omitempty"` + }{}) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(encoded)) +} + +// TestSeverityIsAWireNameAsAMapKey covers ValidationSummary.FindingsBySeverity, +// which is keyed by Severity. For a map key the encoding/json package consults +// neither String nor MarshalJSON, only MarshalText, so the counts stay keyed by +// name rather than by number. +func TestSeverityIsAWireNameAsAMapKey(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(map[fgaerrors.Severity]int{ + fgaerrors.SeverityError: 3, + fgaerrors.SeverityWarning: 1, + fgaerrors.SeverityAdvisory: 2, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"error":3,"warning":1,"advisory":2}`, string(encoded)) + + // Decoding a map key takes the same route: the key type has to implement + // UnmarshalText for encoding/json to reach it at all, otherwise an integer key + // is parsed as a number and "error" fails. + var decoded map[fgaerrors.Severity]int + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, map[fgaerrors.Severity]int{ + fgaerrors.SeverityError: 3, + fgaerrors.SeverityWarning: 1, + fgaerrors.SeverityAdvisory: 2, + }, decoded) +} + +// TestModelErrorKindWireNames checks each category marshals and unmarshals under +// its wire name. The names are API: the Go severity fixtures assert on them. +func TestModelErrorKindWireNames(t *testing.T) { + t.Parallel() + + tests := map[fgaerrors.ModelErrorKind]string{ + fgaerrors.ErrorKindObjectType: `"object-type"`, + fgaerrors.ErrorKindRelation: `"relation"`, + fgaerrors.ErrorKindRelationCondition: `"relation-condition"`, + fgaerrors.ErrorKindCondition: `"condition"`, + fgaerrors.ErrorKindInvalidModel: `"invalid-model"`, + } + + for errorType, wantJSON := range tests { + t.Run(errorType.String(), func(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(errorType) + require.NoError(t, err) + assert.JSONEq(t, wantJSON, string(encoded)) + + var decoded fgaerrors.ModelErrorKind + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, errorType, decoded, "round trip must be lossless") + + assert.True(t, errorType.IsValid()) + }) + } +} + +// TestModelErrorKindRejectsUnknownValues covers both ends of the mapping: a +// number with no name must not reach a consumer as a bare integer, and a name +// this package does not declare must not decode to a category. +func TestModelErrorKindRejectsUnknownValues(t *testing.T) { + t.Parallel() + + _, err := json.Marshal(fgaerrors.ModelErrorKind(99)) + require.ErrorIs(t, err, fgaerrors.ErrUnknownModelErrorKind, + "an undeclared category must fail to marshal rather than ship as 99") + + var decoded fgaerrors.ModelErrorKind + require.ErrorIs(t, json.Unmarshal([]byte(`"not-a-category"`), &decoded), + fgaerrors.ErrUnknownModelErrorKind) + + assert.False(t, fgaerrors.ModelErrorKind(99).IsValid()) + assert.False(t, fgaerrors.ModelErrorKindUnspecified.IsValid()) +} + +// TestModelErrorKindUnspecifiedIsNotACategory checks the zero value is not a +// category. The constants count from one so a finding that never set one does not +// read as being about an object type. +func TestModelErrorKindUnspecifiedIsNotACategory(t *testing.T) { + t.Parallel() + + assert.NotEqual(t, fgaerrors.ModelErrorKindUnspecified, fgaerrors.ErrorKindObjectType, + "the first real category must not be the zero value") + assert.Equal(t, 0, int(fgaerrors.ModelErrorKindUnspecified)) + assert.Empty(t, fgaerrors.ModelErrorKindUnspecified.String()) + + // omitempty drops it, so a finding with no category ships without the field + // rather than with a wrong one. + encoded, err := json.Marshal(struct { + Category fgaerrors.ModelErrorKind `json:"category,omitempty"` + }{}) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(encoded)) +} + +// TestSentinelsAreDistinct guards against a copy-paste leaving two names pointing +// at one value, which would make errors.Is match the wrong condition. +func TestSentinelsAreDistinct(t *testing.T) { + t.Parallel() + + sentinels := map[string]error{ + "ErrInvalidSchemaVersion": fgaerrors.ErrInvalidSchemaVersion, + "ErrSchemaVersionUnsupported": fgaerrors.ErrSchemaVersionUnsupported, + "ErrSchemaVersionRequired": fgaerrors.ErrSchemaVersionRequired, + "ErrReservedKeywords": fgaerrors.ErrReservedKeywords, + "ErrInvalidName": fgaerrors.ErrInvalidName, + "ErrDuplicateDefinition": fgaerrors.ErrDuplicateDefinition, + "ErrObjectTypeUndefined": fgaerrors.ErrObjectTypeUndefined, + "ErrRelationUndefined": fgaerrors.ErrRelationUndefined, + "ErrInvalidType": fgaerrors.ErrInvalidType, + "ErrInvalidRelationType": fgaerrors.ErrInvalidRelationType, + "ErrInvalidRelationOnTupleset": fgaerrors.ErrInvalidRelationOnTupleset, + "ErrInvalidRelationOnTuplesetNotDirect": fgaerrors.ErrInvalidRelationOnTuplesetNotDirect, + "ErrNoEntrypoints": fgaerrors.ErrNoEntrypoints, + "ErrDirectlyAssignableRelation": fgaerrors.ErrDirectlyAssignableRelation, + "ErrInvalidWildcard": fgaerrors.ErrInvalidWildcard, + "ErrConditionUndefined": fgaerrors.ErrConditionUndefined, + "ErrConditionUnReferenced": fgaerrors.ErrConditionUnReferenced, + "ErrConditionNameMismatch": fgaerrors.ErrConditionNameMismatch, + "ErrMultipleModulesInFile": fgaerrors.ErrMultipleModulesInFile, + } + + seenMessages := make(map[string]string, len(sentinels)) + + for name, sentinel := range sentinels { + require.Errorf(t, sentinel, "%s is nil", name) + + for otherName, other := range sentinels { + if name == otherName { + continue + } + + require.NotErrorIsf(t, sentinel, other, + "%s and %s are the same value; errors.Is cannot tell them apart", name, otherName) + } + + if previous, duplicate := seenMessages[sentinel.Error()]; duplicate { + t.Errorf("%s and %s have the identical message %q", name, previous, sentinel.Error()) + } + + seenMessages[sentinel.Error()] = name + } +} diff --git a/pkg/go/errors/sentinels.go b/pkg/go/errors/sentinels.go new file mode 100644 index 00000000..73851944 --- /dev/null +++ b/pkg/go/errors/sentinels.go @@ -0,0 +1,99 @@ +package errors + +import "errors" + +// Sentinel errors for the conditions model validation reports. +// +// Callers branch on what went wrong with errors.Is rather than on message text. +// Every validation finding wraps exactly one of these, and only conditions the +// validator actually reports get one. +var ( + // ErrInvalidSchemaVersion is reported for a schema version that was never a + // valid one, as against one that is no longer supported. + ErrInvalidSchemaVersion = errors.New("invalid schema version") + + // ErrSchemaVersionUnsupported is reported for a schema version that was + // once valid but is no longer supported. + ErrSchemaVersionUnsupported = errors.New("schema version no longer supported") + + // ErrSchemaVersionRequired is reported when a model declares no schema + // version. + ErrSchemaVersionRequired = errors.New("schema version required") + + // ErrReservedKeywords is reported when a type or relation is named with a + // reserved word. + ErrReservedKeywords = errors.New("self and this are reserved keywords") + + // ErrInvalidName is reported when a type or relation name does not match + // the naming rules. + ErrInvalidName = errors.New("invalid name") + + // ErrDuplicateDefinition is reported when a type, relation or type + // restriction is defined more than once. + ErrDuplicateDefinition = errors.New("duplicate definition") + + // ErrObjectTypeUndefined is reported when a model references an object type + // that is not defined. + ErrObjectTypeUndefined = errors.New("undefined object type") + + // ErrRelationUndefined is reported when a model references a relation that + // is not defined on the type it is used with. + ErrRelationUndefined = errors.New("undefined relation") + + // ErrInvalidType is reported when a type restriction names something that + // is not a valid type. + ErrInvalidType = errors.New("invalid type") + + // ErrInvalidRelationType is reported when a relation is not valid for the + // type it is referenced against. + ErrInvalidRelationType = errors.New("invalid relation for type") + + // ErrInvalidRelationOnTupleset is reported when a tupleset relation + // references a relation that does not exist on the related type. + ErrInvalidRelationOnTupleset = errors.New("invalid relation on tupleset") + + // ErrInvalidRelationOnTuplesetNotDirect is reported when a relation used + // inside a `from` clause is not a direct relation. + ErrInvalidRelationOnTuplesetNotDirect = errors.New( + "relations that are referenced in a tupleset must be defined with a direct relation") + + // ErrNoEntrypoints is reported when a relation can never be satisfied, + // either because nothing can enter it or because it only refers back to + // itself. + ErrNoEntrypoints = errors.New("no entrypoints defined") + + // ErrDirectlyAssignableRelation is reported when an assignable relation + // declares no assignable types. + ErrDirectlyAssignableRelation = errors.New("a direct assignment must contain at least one object type or userset") + + // ErrInvalidWildcard is reported when a wildcard is used somewhere it is + // not permitted, including alongside a relation in the same type + // restriction. + ErrInvalidWildcard = errors.New("invalid wildcard usage") + + // ErrConditionUndefined is reported when a relation references a condition + // that the model does not define. + ErrConditionUndefined = errors.New("condition is not defined") + + // ErrConditionUnReferenced is reported when a condition is defined but + // never used. + ErrConditionUnReferenced = errors.New("condition is defined but not referenced") + + // ErrConditionNameMismatch is reported when a condition's key differs from + // the name declared inside it. + ErrConditionNameMismatch = errors.New("condition name does not match its nested name") + + // ErrMultipleModulesInFile is reported when one file declares more than one + // module. + ErrMultipleModulesInFile = errors.New("file contains multiple modules") + + // ErrUnknownModelErrorKind is returned when a ModelErrorKind has no wire + // name, either marshalling a value this package does not declare or reading + // a name it does not recognise. It is not a validation finding. + ErrUnknownModelErrorKind = errors.New("unknown model error type") + + // ErrUnknownSeverity is returned when a Severity has no wire name, either + // marshalling a value this package does not declare or reading a name it + // does not recognise. It is not a validation finding. + ErrUnknownSeverity = errors.New("unknown severity") +) diff --git a/pkg/go/errors/severity.go b/pkg/go/errors/severity.go new file mode 100644 index 00000000..41c68c14 --- /dev/null +++ b/pkg/go/errors/severity.go @@ -0,0 +1,100 @@ +package errors + +import "fmt" + +// Severity states whether a finding makes a model invalid, or only reports +// something about a model that stays valid. +// +// It serialises as its wire name, so the names below are API and the numbers are +// not. +type Severity int + +// SeverityUnspecified is the zero value, so a finding that never set a severity +// cannot pass for one that did. It has no wire name, omitempty drops it, and +// Blocks treats it as blocking. +const SeverityUnspecified Severity = 0 + +const ( + // SeverityError means the model is invalid. Validation fails. + SeverityError Severity = iota + 1 + + // SeverityWarning means the model is valid today but relies on something a + // future version may not accept. Validation does not fail. + SeverityWarning + + // SeverityAdvisory means the model is valid, but a request against it may not + // behave the way the author expects, depending on the tuples written and the + // checks issued. Validation does not fail. + SeverityAdvisory +) + +// severityNames maps each severity to its wire name. A severity missing from here +// fails to marshal, so a constant added without a name is caught. +var severityNames = map[Severity]string{ + SeverityError: "error", + SeverityWarning: "warning", + SeverityAdvisory: "advisory", +} + +// severityValues is the reverse of severityNames, built from it so the two +// cannot disagree. +var severityValues = func() map[string]Severity { + values := make(map[string]Severity, len(severityNames)) + for severity, name := range severityNames { + values[name] = severity + } + + return values +}() + +// String returns the wire name, or a diagnostic form for a value with none. +func (s Severity) String() string { + if name, ok := severityNames[s]; ok { + return name + } + + if s == SeverityUnspecified { + return "" + } + + return fmt.Sprintf("Severity(%d)", int(s)) +} + +// IsValid reports whether s is a declared severity with a wire name. +func (s Severity) IsValid() bool { + _, ok := severityNames[s] + + return ok +} + +// Blocks reports whether a finding of this severity makes validation fail. +// +// Only the severities declared as non-blocking answer false, so an unset or +// undeclared value blocks: a severity that cannot be recognised must not let an +// invalid model pass. +func (s Severity) Blocks() bool { + return s != SeverityWarning && s != SeverityAdvisory +} + +// MarshalText emits the wire name, so the JSON carries "warning". +func (s Severity) MarshalText() ([]byte, error) { + name, ok := severityNames[s] + if !ok { + return nil, fmt.Errorf("%w: %d", ErrUnknownSeverity, int(s)) + } + + return []byte(name), nil +} + +// UnmarshalText resolves a wire name back to its severity, rejecting any name +// this package does not declare. +func (s *Severity) UnmarshalText(text []byte) error { + severity, ok := severityValues[string(text)] + if !ok { + return fmt.Errorf("%w: %q", ErrUnknownSeverity, text) + } + + *s = severity + + return nil +} diff --git a/pkg/go/validation/complex_operation_validation.go b/pkg/go/validation/complex_operation_validation.go index 94f23282..c4c3bb05 100644 --- a/pkg/go/validation/complex_operation_validation.go +++ b/pkg/go/validation/complex_operation_validation.go @@ -1,6 +1,9 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -36,8 +39,10 @@ func validateComplexOperations(collector *ErrorCollector, validator *SemanticVal } opValidator := newComplexOperationValidator(validator) for _, typeDef := range model.GetTypeDefinitions() { - for relationName, userset := range typeDef.GetRelations() { - opValidator.validateUsersetOperations(collector, typeDef.GetType(), relationName, userset, lines) + relations := typeDef.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + opValidator.validateUsersetOperations(collector, typeDef.GetType(), relationName, + relations[relationName], lines) } } } diff --git a/pkg/go/validation/condition_validation.go b/pkg/go/validation/condition_validation.go index 067468aa..1ea30566 100644 --- a/pkg/go/validation/condition_validation.go +++ b/pkg/go/validation/condition_validation.go @@ -1,6 +1,9 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -43,8 +46,12 @@ func (cv *ConditionValidator) buildConditionMaps() { 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) + // Relations in name order: the references collected here are reported in + // the order they were appended, so ranging the map would vary it. + relationsMetadata := metaProto.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + cv.scanRelationMetadataForConditions(typeDef.GetType(), relationName, + relationsMetadata[relationName]) } } } @@ -75,8 +82,9 @@ func ValidateUnusedConditions(collector *ErrorCollector, model *openfgav1.Author } func validateUnusedConditions(collector *ErrorCollector, validator *ConditionValidator, lines []string) { - for conditionName, condition := range validator.definedConds { + for _, conditionName := range slices.Sorted(maps.Keys(validator.definedConds)) { if !validator.usedConds[conditionName] { + condition := validator.definedConds[conditionName] lineIndex := GetConditionLineNumber(conditionName, lines, nil) meta := &Meta{ File: condition.GetMetadata().GetSourceInfo().GetFile(), @@ -97,7 +105,7 @@ func ValidateConditionReferences(collector *ErrorCollector, model *openfgav1.Aut func validateConditionReferences(collector *ErrorCollector, validator *ConditionValidator, lines []string) { model := validator.model - for conditionName := range validator.usedConds { + for _, conditionName := range slices.Sorted(maps.Keys(validator.usedConds)) { if _, exists := validator.definedConds[conditionName]; !exists { for _, ref := range validator.conditionRefs[conditionName] { // Anchor the relation line lookup to the referencing type's @@ -127,7 +135,9 @@ func ValidateConditionConsistency(collector *ErrorCollector, model *openfgav1.Au if model == nil { return } - for conditionKey, condition := range model.GetConditions() { + conditions := model.GetConditions() + for _, conditionKey := range slices.Sorted(maps.Keys(conditions)) { + condition := conditions[conditionKey] if condition == nil { continue } diff --git a/pkg/go/validation/condition_validation_test.go b/pkg/go/validation/condition_validation_test.go index 6c64c0cb..d0940775 100644 --- a/pkg/go/validation/condition_validation_test.go +++ b/pkg/go/validation/condition_validation_test.go @@ -33,8 +33,8 @@ func TestNewConditionValidator(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "is_owner", + Type: "user", + Condition: "is_owner", }, }, }, @@ -88,7 +88,7 @@ func TestConditionValidator_GetUsedConditions(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", + Type: "user", Condition: "used_condition", }, }, @@ -120,7 +120,7 @@ func TestConditionValidator_GetConditionReferences(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", + Type: "user", Condition: "test_condition", }, }, @@ -128,7 +128,7 @@ func TestConditionValidator_GetConditionReferences(t *testing.T) { "editor": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", + Type: "user", Condition: "test_condition", }, }, @@ -177,8 +177,8 @@ func TestValidateUnusedConditions(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "used_condition", + Type: "user", + Condition: "used_condition", }, }, }, @@ -191,7 +191,7 @@ func TestValidateUnusedConditions(t *testing.T) { collector := NewErrorCollector(nil) ValidateUnusedConditions(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Empty(t, errors) }) @@ -209,8 +209,8 @@ func TestValidateUnusedConditions(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "used_condition", + Type: "user", + Condition: "used_condition", }, }, }, @@ -223,7 +223,7 @@ func TestValidateUnusedConditions(t *testing.T) { collector := NewErrorCollector(nil) ValidateUnusedConditions(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, ConditionNotUsed, errors[0].Metadata.ErrorType) assert.Equal(t, "unused_condition", errors[0].Metadata.Symbol) @@ -246,8 +246,8 @@ func TestValidateUnusedConditions(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "used_condition", + Type: "user", + Condition: "used_condition", }, }, }, @@ -260,7 +260,7 @@ func TestValidateUnusedConditions(t *testing.T) { collector := NewErrorCollector(nil) ValidateUnusedConditions(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 2) // Check that both unused conditions are reported @@ -288,8 +288,8 @@ func TestValidateConditionReferences(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "valid_condition", + Type: "user", + Condition: "valid_condition", }, }, }, @@ -302,7 +302,7 @@ func TestValidateConditionReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateConditionReferences(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Empty(t, errors) }) @@ -316,8 +316,8 @@ func TestValidateConditionReferences(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "undefined_condition", + Type: "user", + Condition: "undefined_condition", }, }, }, @@ -330,7 +330,7 @@ func TestValidateConditionReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateConditionReferences(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, ConditionNotDefined, errors[0].Metadata.ErrorType) assert.Equal(t, "undefined_condition", errors[0].Metadata.Symbol) @@ -347,16 +347,16 @@ func TestValidateConditionReferences(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "undefined1", + Type: "user", + Condition: "undefined1", }, }, }, "editor": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "undefined2", + Type: "user", + Condition: "undefined2", }, }, }, @@ -369,7 +369,7 @@ func TestValidateConditionReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateConditionReferences(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 2) // Check that both undefined conditions are reported @@ -394,7 +394,7 @@ func TestValidateConditionConsistency(t *testing.T) { collector := NewErrorCollector(nil) ValidateConditionConsistency(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Empty(t, errors) }) @@ -408,7 +408,7 @@ func TestValidateConditionConsistency(t *testing.T) { collector := NewErrorCollector(nil) ValidateConditionConsistency(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -424,7 +424,7 @@ func TestValidateConditionConsistency(t *testing.T) { collector := NewErrorCollector(nil) ValidateConditionConsistency(collector, model, nil) - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, collector.AllFindings()) }) } @@ -444,12 +444,12 @@ func TestScanForConditionUsage(t *testing.T) { "viewer": { DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ { - Type: "user", - Condition: "condition1", + Type: "user", + Condition: "condition1", }, { - Type: "group", - Condition: "condition2", + Type: "group", + Condition: "condition2", }, }, }, diff --git a/pkg/go/validation/context.go b/pkg/go/validation/context.go index 57b9b99f..c5c4f5c2 100644 --- a/pkg/go/validation/context.go +++ b/pkg/go/validation/context.go @@ -4,7 +4,7 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ValidationContext holds the state during model validation +// ValidationContext holds the state during model validation. type ValidationContext struct { TypeMap map[string]*openfgav1.TypeDefinition VisitedRelations map[string]map[string]bool diff --git a/pkg/go/validation/criticality_test.go b/pkg/go/validation/criticality_test.go new file mode 100644 index 00000000..5ece9e2c --- /dev/null +++ b/pkg/go/validation/criticality_test.go @@ -0,0 +1,168 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// TestCriticalImpliesBlocking checks no code is critical without also being +// blocking. Criticality and severity are fields on the same errorInfo entry, so a +// code cannot claim to invalidate the whole model and not fail validation. +func TestCriticalImpliesBlocking(t *testing.T) { + t.Parallel() + + for errorType, info := range errorInfoByType { + if !info.Critical { + continue + } + + assert.Equalf(t, fgaerrors.SeverityError, info.Severity, + "%q is critical but its severity is %q: a finding cannot invalidate the whole "+ + "model and leave it valid", errorType, info.Severity) + } +} + +// TestCriticalErrorTypesAreEmitted checks criticality is only claimed for a code +// some Raise* method raises. +// +// It reads the raise sites in the collector, not the callers of those methods, so a +// code raised only by a Raise* method that nothing calls still counts here. +func TestCriticalErrorTypesAreEmitted(t *testing.T) { + t.Parallel() + + emitted := emittedErrorTypes(t) + require.NotEmpty(t, emitted) + + for errorType, info := range errorInfoByType { + if !info.Critical { + continue + } + + name := errorTypeConstantName(t, errorType) + _, ok := emitted[name] + assert.Truef(t, ok, "%s is marked critical but no Raise* method raises it", name) + } +} + +// TestUnemittedErrorTypesAreNotCritical checks the same from the other side, so a +// change that starts emitting one of these codes has to decide its criticality +// rather than inherit one. +func TestUnemittedErrorTypesAreNotCritical(t *testing.T) { + t.Parallel() + + for errorType := range unemittedErrorTypes { + assert.Falsef(t, isCriticalErrorType(errorType), + "%q is not emitted, so calling it critical asserts nothing", errorType) + } +} + +// TestCriticalityOfEveryEmittedCode pins the criticality of every declared code, so +// a change to errorInfo that alters one has to be made here as well. +func TestCriticalityOfEveryEmittedCode(t *testing.T) { + t.Parallel() + + wantCritical := map[ValidationErrorType]bool{ + RelationNoEntrypoint: true, + UndefinedType: true, + UndefinedRelation: true, + InvalidRelationType: true, + DuplicatedError: true, + InvalidSchema: true, + MultipleModulesInFile: true, + } + + // Nothing raises these two, so they are held at not-critical rather than listed + // above. + neverRaised := map[ValidationErrorType]struct{}{ + CyclicRelation: {}, + InvalidSchemaVersion: {}, + } + + for _, errorType := range allErrorTypes { + if _, unemitted := neverRaised[errorType]; unemitted { + assert.Falsef(t, isCriticalErrorType(errorType), + "%q is never raised and must not be claimed critical", errorType) + + continue + } + + assert.Equalf(t, wantCritical[errorType], isCriticalErrorType(errorType), + "criticality of %q does not match the list in this test", errorType) + } +} + +// TestHasCriticalErrorsThroughValidation checks criticality end to end, and that a +// non-critical error leaves the flag unset. Otherwise the field would be +// indistinguishable from HasErrors. +func TestHasCriticalErrorsThroughValidation(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + dsl string + wantCritical bool + wantValid bool + }{ + "undefined type is an error but not critical": { + dsl: `model + schema 1.1 +type document + relations + define viewer: [user] +`, + wantCritical: false, + wantValid: false, + }, + "duplicate type is critical": { + dsl: `model + schema 1.1 +type user +type document +type document +`, + wantCritical: true, + wantValid: false, + }, + "relation with no entrypoint is critical": { + dsl: `model + schema 1.1 +type user +type document + relations + define viewer: writer + define writer: viewer +`, + wantCritical: true, + wantValid: false, + }, + "valid model has neither": { + dsl: `model + schema 1.1 +type user +type document + relations + define viewer: [user] +`, + wantCritical: false, + wantValid: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + report := CreateValidationReport(modelFromDSL(t, test.dsl), test.dsl, DefaultEngineOptions()) + + assert.Equal(t, test.wantCritical, report.HasCriticalErrors()) + assert.Equal(t, test.wantValid, report.IsValid()) + + if test.wantCritical { + require.False(t, report.IsValid(), "a critical finding must also block") + } + }) + } +} diff --git a/pkg/go/validation/cycle_detection.go b/pkg/go/validation/cycle_detection.go index e1c831ff..8563b50b 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" ) @@ -50,9 +53,10 @@ func validateCyclesAndEntryPoints(collector *ErrorCollector, validator *Semantic } typeName := typeDef.GetType() typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - for relationName, userset := range relations { + for _, relationName := range slices.Sorted(maps.Keys(relations)) { meta := relationMeta(typeDef, relationName) - result := detector.hasEntryPointOrLoop(typeName, relationName, userset, map[string]map[string]bool{}) + result := detector.hasEntryPointOrLoop(typeName, relationName, relations[relationName], + map[string]map[string]bool{}) if !result.hasEntry { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) if result.loop { @@ -85,8 +89,12 @@ func relationMeta(typeDef *openfgav1.TypeDefinition, relationName string) *Meta } // 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. +// The visited map tracks type#relation pairs already on the current traversal. +// +// 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 diff --git a/pkg/go/validation/cycle_detection_stress_test.go b/pkg/go/validation/cycle_detection_stress_test.go index c238ed2f..6482b7f7 100644 --- a/pkg/go/validation/cycle_detection_stress_test.go +++ b/pkg/go/validation/cycle_detection_stress_test.go @@ -60,7 +60,7 @@ func TestCycleDetection_DeepChainTerminatesWithEntry(t *testing.T) { if collector.HasErrors() { 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 errors: %v", collector.Count(), collector.AllFindings()) } } @@ -80,7 +80,7 @@ func TestCycleDetection_WideUnionTerminatesWithEntry(t *testing.T) { if collector.HasErrors() { t.Fatalf("wide union of resolvable members should have an entry point, "+ - "got %d errors: %v", collector.Count(), collector.GetErrors()) + "got %d errors: %v", collector.Count(), collector.AllFindings()) } } @@ -135,7 +135,7 @@ type doc // `loops` and `selfref` legitimately have no entry point and are reported. // `mixed` and `direct` must NOT be reported. - for _, e := range collector.GetErrors() { + for _, e := range collector.AllFindings() { if strings.Contains(e.Message, "mixed") || strings.Contains(e.Message, "`direct`") { t.Fatalf("relation with a resolvable union branch should have an entry "+ "point, but got error: %s", e.Message) @@ -170,6 +170,6 @@ func TestCycleDetection_DeepChainCountStable(t *testing.T) { // 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()) + depth+1, collector.Count(), collector.AllFindings()) } } diff --git a/pkg/go/validation/cycle_detection_test.go b/pkg/go/validation/cycle_detection_test.go index 8dbb736f..f7f986ef 100644 --- a/pkg/go/validation/cycle_detection_test.go +++ b/pkg/go/validation/cycle_detection_test.go @@ -54,7 +54,7 @@ func TestCycleDetector(t *testing.T) { collector := NewErrorCollector(nil) ValidateCyclesAndEntryPoints(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() // Each relation is impossible: one error per relation, all RelationNoEntrypoint. assert.Len(t, errors, 2) for _, err := range errors { @@ -95,7 +95,7 @@ func TestCycleDetector(t *testing.T) { collector := NewErrorCollector(nil) ValidateCyclesAndEntryPoints(collector, model, nil) - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, collector.AllFindings()) }) t.Run("Computed chain terminating in a direct assignment is reachable", func(t *testing.T) { @@ -121,7 +121,7 @@ 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, collector.AllFindings()) }) } @@ -215,7 +215,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{ diff --git a/pkg/go/validation/duplicate_detection.go b/pkg/go/validation/duplicate_detection.go index b476d87a..e705c9fd 100644 --- a/pkg/go/validation/duplicate_detection.go +++ b/pkg/go/validation/duplicate_detection.go @@ -1,6 +1,9 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -165,8 +168,9 @@ func ValidateDuplicates(collector *ErrorCollector, model *openfgav1.Authorizatio 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) + relationsMetadata := metaProto.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + CheckForDuplicateTypeNamesInRelation(collector, relationsMetadata[relationName], relationName, typeName, meta, typeLineIndex, lines) CheckForDuplicatesInRelation(collector, typeDef, relationName, typeLineIndex, lines) } } diff --git a/pkg/go/validation/duplicate_detection_test.go b/pkg/go/validation/duplicate_detection_test.go index e09dbb43..a177ba17 100644 --- a/pkg/go/validation/duplicate_detection_test.go +++ b/pkg/go/validation/duplicate_detection_test.go @@ -55,7 +55,7 @@ func TestDuplicateTypeTracker_CheckAndAddType(t *testing.T) { tracker.CheckAndAddType(typeName, collector, meta, nil) } - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 && tt.expectedDuplicate != "" { @@ -170,7 +170,7 @@ func TestCheckForDuplicateTypeNamesInRelation(t *testing.T) { CheckForDuplicateTypeNamesInRelation(collector, tt.relationMetadata, tt.relationName, tt.typeName, meta, nil, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 { @@ -377,7 +377,7 @@ func TestCheckForDuplicatesInRelation(t *testing.T) { CheckForDuplicatesInRelation(collector, tt.typeDef, tt.relationName, nil, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 { @@ -499,7 +499,7 @@ func TestValidateDuplicates(t *testing.T) { ValidateDuplicates(collector, tt.model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) for i, expectedType := range tt.expectedErrorTypes { @@ -582,7 +582,7 @@ func TestCheckDuplicatesInUnion(t *testing.T) { checkDuplicatesInOperands(collector, tt.union, "test_relation", "test_type", meta, nil, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 { @@ -605,7 +605,7 @@ func TestValidateDuplicates_Integration(t *testing.T) { } ValidateDuplicates(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) assert.Contains(t, errors[0].Message, "is a duplicate") @@ -634,7 +634,7 @@ func TestValidateDuplicates_Integration(t *testing.T) { } ValidateDuplicates(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() 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 index 386a091e..70a48ea9 100644 --- a/pkg/go/validation/error_collector.go +++ b/pkg/go/validation/error_collector.go @@ -1,8 +1,11 @@ package validation import ( + "errors" "fmt" "strings" + + fgaerrors "github.com/openfga/language/pkg/go/errors" ) // wordIndex returns the index of symbol in rawLine matched on word boundaries, @@ -51,8 +54,8 @@ func isWordChar(b byte) bool { (b >= 'A' && b <= 'Z') } -// ErrorCollector collects validation errors during model validation -// This is equivalent to the JS ExceptionCollector class +// 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 @@ -66,30 +69,78 @@ func NewErrorCollector(lines []string) *ErrorCollector { } } -// GetErrors returns all collected errors. -func (c *ErrorCollector) GetErrors() []*ValidationError { +// AllFindings returns every collected finding, blocking or not. The collector is the +// raw record; ValidationErrors is where findings are filtered by severity, which is +// why this is not called GetErrors: on ValidationErrors that name means the blocking +// ones only. +func (c *ErrorCollector) AllFindings() []*ValidationError { return c.errors } -// HasErrors returns true if any errors have been collected. +// HasErrors reports whether any collected finding makes the model invalid. +// +// The cascade in RunAllValidations gates on this, so it counts blocking findings +// only: one advisory must not skip every phase that runs after it. func (c *ErrorCollector) HasErrors() bool { - return len(c.errors) > 0 + for _, err := range c.errors { + if err.Blocks() { + return true + } + } + return false } -// Count returns the number of errors collected. +// Count returns the number of collected findings that make the model invalid. func (c *ErrorCollector) Count() int { + count := 0 + for _, err := range c.errors { + if err.Blocks() { + count++ + } + } + return count +} + +// CountAll returns the total number of collected findings, blocking or not. +func (c *ErrorCollector) CountAll() int { return len(c.errors) } +// scope names the model entity a finding is about, so addScopedError can build the +// cause and derive the metadata from one description. A zero scope means the raise +// site has nothing to add beyond the symbol, and the table's category stands alone. +type scope struct { + objectType string + relation string + condition string + + // offendingType is the enclosing type a finding about another type was written + // in, matching JS's wire field of the same name. Metadata only: no scoped error + // type has a slot for it. + offendingType string + + // category overrides the table default when set, for codes raised from places + // with different scopes: a duplicate type and a duplicate type restriction share + // one code without being the same kind of finding. + category fgaerrors.ModelErrorKind +} + // 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 + c.addScopedError(message, errorType, symbol, lineIndex, meta, customResolver, scope{}) +} + +// addScopedError adds an error that knows which type, relation or condition it +// concerns. Callers with nothing to add beyond the symbol use addError instead. +func (c *ErrorCollector) addScopedError(message string, errorType ValidationErrorType, symbol string, + lineIndex *int, meta *Meta, customResolver ErrorCustomResolver, errorScope scope) { + var line *Range + var column *Range // Calculate line and column positions if lineIndex is provided if lineIndex != nil && *lineIndex >= 0 && *lineIndex < len(c.lines) { - line = &LineRange{Start: *lineIndex, End: *lineIndex} + line = &Range{Start: *lineIndex, End: *lineIndex} // Find symbol position in line for column calculation, matching on word // boundaries as the reference does. @@ -101,28 +152,49 @@ func (c *ErrorCollector) addError(message string, errorType ValidationErrorType, } if symbolPos >= 0 { - column = &ColumnRange{ + column = &Range{ Start: symbolPos, End: symbolPos + len(symbol), } } } + entry := lookupErrorInfo(errorType) + + category := entry.Category + if errorScope.category != fgaerrors.ModelErrorKindUnspecified { + category = errorScope.category + } + + // The cause carries the scope and the metadata is derived from it, so the JSON + // and the errors.As payload cannot disagree. offendingType is metadata only, so + // it comes straight off the scope. + cause := newScopedCause(category, errorScope, entry.Cause) + objectType, relation, condition := causeScope(cause) + metadata := &ErrorMetadata{ - Symbol: symbol, - ErrorType: errorType, + Symbol: symbol, + ErrorType: errorType, + OffendingType: errorScope.offendingType, + Type: objectType, + Relation: relation, + Condition: condition, } if meta != nil { + // Module goes in the metadata, file on the error itself, matching the + // JS implementation. metadata.Module = meta.Module - // Set file in both metadata and error for consistency with JS implementation } validationErr := &ValidationError{ Message: message, + Severity: entry.Severity, + Category: category, Line: line, Column: column, Metadata: metadata, + Cause: cause, } if meta != nil { @@ -132,27 +204,112 @@ func (c *ErrorCollector) addError(message string, errorType ValidationErrorType, c.errors = append(c.errors, validationErr) } +// newScopedCause wraps sentinel in the error type matching category, carrying +// whichever scope fields that type declares. Returns nil when there is no sentinel, +// which is the case for codes absent from the table. +func newScopedCause(category fgaerrors.ModelErrorKind, errorScope scope, sentinel error) error { + if sentinel == nil { + return nil + } + + switch category { + case fgaerrors.ErrorKindObjectType: + return &fgaerrors.ErrObjectType{ + ObjectType: errorScope.objectType, + Cause: sentinel, + } + case fgaerrors.ErrorKindRelation: + return &fgaerrors.ErrRelation{ + ObjectType: errorScope.objectType, + Relation: errorScope.relation, + Cause: sentinel, + } + case fgaerrors.ErrorKindRelationCondition: + return &fgaerrors.ErrRelationCondition{ + ObjectType: errorScope.objectType, + Relation: errorScope.relation, + Condition: errorScope.condition, + Cause: sentinel, + } + case fgaerrors.ErrorKindCondition: + return &fgaerrors.ErrCondition{ + Condition: errorScope.condition, + Cause: sentinel, + } + default: + // ErrorKindInvalidModel, and anything unrecognised: a finding no part of the + // model owns. + return &fgaerrors.ErrModel{Cause: sentinel} + } +} + +// causeScope reads the scope off whichever error type cause is, so the metadata +// carries exactly the fields that type declares. A cause with no scope to report, +// *ErrModel or nil, yields three empty strings, which omitempty drops. +func causeScope(cause error) (objectType, relation, condition string) { + var ( + objectTypeErr *fgaerrors.ErrObjectType + relationErr *fgaerrors.ErrRelation + relationConditionErr *fgaerrors.ErrRelationCondition + conditionErr *fgaerrors.ErrCondition + ) + + switch { + case errors.As(cause, &objectTypeErr): + return objectTypeErr.ObjectType, "", "" + case errors.As(cause, &relationErr): + return relationErr.ObjectType, relationErr.Relation, "" + case errors.As(cause, &relationConditionErr): + return relationConditionErr.ObjectType, relationConditionErr.Relation, relationConditionErr.Condition + case errors.As(cause, &conditionErr): + return "", "", conditionErr.Condition + default: + return "", "", "" + } +} + // RaiseInvalidName raises an invalid name error. func (c *ErrorCollector) RaiseInvalidName(symbol, clause string, typeName *string, lineIndex *int, meta *Meta) { var message string + // A nil typeName means the offending name is a type rather than a relation on + // one, which changes both the message and the scope of the finding. + errorScope := scope{objectType: symbol, category: fgaerrors.ErrorKindObjectType} + if typeName != nil { message = fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", symbol, *typeName, clause) + errorScope = scope{objectType: *typeName, relation: symbol} } else { message = fmt.Sprintf("type '%s' does not match naming rule: '%s'.", symbol, clause) } - c.addError(message, InvalidName, symbol, lineIndex, meta, nil) + + c.addScopedError(message, InvalidName, symbol, lineIndex, meta, nil, errorScope) +} + +// RaiseInvalidConditionName raises an invalid name error for a condition, scoped +// to the condition rather than RaiseInvalidName's type or relation. +func (c *ErrorCollector) RaiseInvalidConditionName(symbol, clause string, lineIndex *int, meta *Meta) { + message := fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", symbol, clause) + c.addScopedError(message, InvalidName, symbol, lineIndex, meta, nil, scope{ + condition: symbol, + category: fgaerrors.ErrorKindCondition, + }) } // 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) + c.addScopedError(message, ReservedTypeKeywords, symbol, lineIndex, meta, nil, scope{ + objectType: symbol, + }) } // RaiseReservedRelationName raises a reserved relation name error. -func (c *ErrorCollector) RaiseReservedRelationName(symbol string, lineIndex *int, meta *Meta) { +func (c *ErrorCollector) RaiseReservedRelationName(symbol, typeName string, lineIndex *int, meta *Meta) { message := "a relation cannot be named 'self' or 'this'." - c.addError(message, ReservedRelationKeywords, symbol, lineIndex, meta, nil) + c.addScopedError(message, ReservedRelationKeywords, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: symbol, + }) } // RaiseTupleUsersetRequiresDirect raises an error for tuple-to-userset not being direct. @@ -168,56 +325,85 @@ func (c *ErrorCollector) RaiseTupleUsersetRequiresDirect(symbol, typeName, relat return wordIdx } - c.addError(message, TuplesetNotDirect, symbol, lineIndex, meta, customResolver) + c.addScopedError(message, TuplesetNotDirect, symbol, lineIndex, meta, customResolver, scope{ + objectType: typeName, + relation: relation, + }) } // 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) + // A duplicate type is about the type, not a relation on it, so this overrides + // DuplicatedError's relation-scoped default. + c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ + objectType: symbol, + category: fgaerrors.ErrorKindObjectType, + }) } // 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) + c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + }) } // 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) + // The undefined type is the subject; parentTypeName is only where it was + // referenced from, so the scope names the type that does not exist. + c.addScopedError(message, UndefinedType, typeName, lineIndex, meta, nil, scope{ + objectType: typeName, + }) } // 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) + c.addScopedError(message, UndefinedRelation, relationName, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + }) } // 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) + c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + }) } // 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) + c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ + relation: symbol, + }) } // 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) + c.addScopedError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: symbol, + }) } // 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) + c.addScopedError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: symbol, + }) } // RaiseInvalidRelationOnTupleset raises an error for invalid relation on tupleset. @@ -225,14 +411,21 @@ func (c *ErrorCollector) RaiseInvalidRelationOnTupleset(symbol, typeName, typeDe 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) + c.addScopedError(message, InvalidRelationOnTupleset, symbol, lineIndex, meta, nil, scope{ + objectType: typeDef, + relation: relationName, + }) } // 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) + c.addScopedError(message, InvalidRelationType, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + offendingType: offendingType, + }) } // RaiseInvalidType raises an error for invalid type. @@ -251,27 +444,39 @@ func (c *ErrorCollector) RaiseInvalidType(symbol, typeName, relation string, met idx := wordIndex(value, sym) return colon + 1 + idx } - c.addError(message, InvalidType, symbol, lineIndex, meta, resolver) + c.addScopedError(message, InvalidType, symbol, lineIndex, meta, resolver, scope{ + objectType: symbol, + }) } // 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) + c.addScopedError(message, AssignableRelationsMustHaveType, symbol, lineIndex, nil, nil, scope{ + relation: symbol, + }) } // 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) + c.addScopedError(message, TypeRestrictionCannotHaveWildcardAndRelation, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relation, + }) } -// RaiseInvalidRelationError raises an error for invalid relation reference. -func (c *ErrorCollector) RaiseInvalidRelationError(symbol, typeName, relation string, validRelations []string, +// RaiseInvalidRelationError reports a rewrite that names a relation the type does +// not define. The message names the missing relation only, as the reference's does; +// it does not list the relations that do exist. +func (c *ErrorCollector) RaiseInvalidRelationError(symbol, typeName, relation string, lineIndex *int, meta *Meta) { message := fmt.Sprintf("the relation `%s` does not exist.", symbol) - c.addError(message, MissingDefinition, symbol, lineIndex, meta, nil) + c.addScopedError(message, MissingDefinition, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relation, + }) } // RaiseInvalidSchemaVersion raises an error for a schema version that was never @@ -298,33 +503,48 @@ func (c *ErrorCollector) RaiseSchemaVersionRequired(symbol string, lineIndex *in // 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) + c.addScopedError(message, DuplicatedError, symbol, lineIndex, nil, nil, scope{ + relation: symbol, + }) } // 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) + // Scoped to the relation the condition is applied to, not the condition's own + // definition: the condition does not exist to have a definition. + c.addScopedError(message, ConditionNotDefined, symbol, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + condition: conditionName, + }) } // 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) + c.addScopedError(message, ConditionNotUsed, symbol, lineIndex, meta, nil, scope{ + condition: symbol, + }) } // 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) + c.addScopedError(message, DifferentNestedConditionName, nestedConditionName, nil, nil, nil, scope{ + condition: condition, + }) } -// RaiseMultipleModulesInSingleFile raises an error for multiple modules in single file. +// RaiseMultipleModulesInSingleFile raises an error for multiple modules in single +// file. The modules are listed in the order the model declares them, and the message +// mirrors the reference. func (c *ErrorCollector) RaiseMultipleModulesInSingleFile(file string, modules []string) { moduleList := strings.Join(modules, ", ") - message := fmt.Sprintf("file '%s' contains multiple modules: %s.", file, moduleList) + message := fmt.Sprintf("file %s would contain multiple module definitions (%s) when transforming to DSL. "+ + "Only one module can be defined per file.", file, moduleList) c.addError(message, MultipleModulesInFile, file, nil, nil, nil) } @@ -333,18 +553,27 @@ func (c *ErrorCollector) RaiseMultipleModulesInSingleFile(file string, modules [ // 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) + c.addScopedError(message, DuplicatedError, operation, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + }) } // 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) + c.addScopedError(message, InvalidRelationType, relationName, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + }) } // 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) + c.addScopedError(message, RelationNoEntrypoint, relationName, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: relationName, + }) } diff --git a/pkg/go/validation/error_collector_test.go b/pkg/go/validation/error_collector_test.go index d9d85ae8..fcb52bde 100644 --- a/pkg/go/validation/error_collector_test.go +++ b/pkg/go/validation/error_collector_test.go @@ -5,9 +5,10 @@ import ( "testing" "github.com/stretchr/testify/assert" -) - + "github.com/stretchr/testify/require" + fgaerrors "github.com/openfga/language/pkg/go/errors" +) func TestWordIndex(t *testing.T) { tests := []struct { @@ -45,13 +46,13 @@ func TestErrorCollector_GetErrors(t *testing.T) { collector := NewErrorCollector(nil) // Initially no errors - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Empty(t, errors) // Add an error collector.RaiseInvalidName("test", "rule", nil, nil, nil) - errors = collector.GetErrors() + errors = collector.AllFindings() assert.Len(t, errors, 1) assert.Contains(t, errors[0].Message, "test") } @@ -112,7 +113,7 @@ func TestErrorCollector_RaiseInvalidName(t *testing.T) { collector := NewErrorCollector(nil) collector.RaiseInvalidName(tt.symbol, tt.clause, tt.typeName, tt.lineIndex, tt.meta) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, tt.expectedMsg, errors[0].Message) assert.Equal(t, tt.expectedType, errors[0].Metadata.ErrorType) @@ -121,6 +122,27 @@ func TestErrorCollector_RaiseInvalidName(t *testing.T) { } } +func TestErrorCollector_RaiseInvalidConditionName(t *testing.T) { + collector := NewErrorCollector(nil) + lineIndex := 5 + meta := &Meta{File: "test.fga", Module: "test"} + + collector.RaiseInvalidConditionName("bad name", "[a-zA-Z]+", &lineIndex, meta) + + errors := collector.AllFindings() + require.Len(t, errors, 1) + assert.Equal(t, "condition 'bad name' does not match naming rule: '[a-zA-Z]+'.", errors[0].Message) + assert.Equal(t, InvalidName, errors[0].Metadata.ErrorType) + assert.Equal(t, "bad name", errors[0].Metadata.Symbol) + assert.Equal(t, fgaerrors.ErrorKindCondition, errors[0].Category) + assert.Equal(t, "bad name", errors[0].Metadata.Condition) + assert.Empty(t, errors[0].Metadata.Type) + + var scoped *fgaerrors.ErrCondition + require.ErrorAs(t, errors[0], &scoped) + assert.Equal(t, "bad name", scoped.Condition) +} + func TestErrorCollector_RaiseReservedTypeName(t *testing.T) { collector := NewErrorCollector(nil) lineIndex := 5 @@ -128,7 +150,7 @@ func TestErrorCollector_RaiseReservedTypeName(t *testing.T) { collector.RaiseReservedTypeName("self", &lineIndex, meta) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -141,13 +163,14 @@ func TestErrorCollector_RaiseReservedRelationName(t *testing.T) { lineIndex := 3 meta := &Meta{File: "test.fga", Module: "test"} - collector.RaiseReservedRelationName("this", &lineIndex, meta) + collector.RaiseReservedRelationName("this", "document", &lineIndex, meta) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) + assert.Equal(t, "document", errors[0].Metadata.Type) } func TestErrorCollector_RaiseTupleUsersetRequiresDirect(t *testing.T) { @@ -163,7 +186,7 @@ func TestErrorCollector_RaiseTupleUsersetRequiresDirect(t *testing.T) { collector.RaiseTupleUsersetRequiresDirect("user", "document", "viewer", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -177,7 +200,7 @@ func TestErrorCollector_RaiseDuplicateTypeName(t *testing.T) { collector.RaiseDuplicateTypeName("document", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -191,7 +214,7 @@ func TestErrorCollector_RaiseDuplicateTypeRestriction(t *testing.T) { collector.RaiseDuplicateTypeRestriction("user", "viewer", "document", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -205,7 +228,7 @@ func TestErrorCollector_RaiseNoEntryPointLoop(t *testing.T) { collector.RaiseNoEntryPointLoop("viewer", "document", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -219,7 +242,7 @@ func TestErrorCollector_RaiseNoEntryPoint(t *testing.T) { collector.RaiseNoEntryPoint("viewer", "document", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -233,7 +256,7 @@ func TestErrorCollector_RaiseInvalidType(t *testing.T) { collector.RaiseInvalidType("unknown_type", "document", "viewer", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -246,7 +269,7 @@ func TestErrorCollector_RaiseAssignableRelationMustHaveTypes(t *testing.T) { collector.RaiseAssignableRelationMustHaveTypes("viewer", &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -257,11 +280,10 @@ 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) + collector.RaiseInvalidRelationError("unknown", "document", "relation", &lineIndex, meta) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -274,7 +296,7 @@ func TestErrorCollector_RaiseSchemaVersionRequired(t *testing.T) { collector.RaiseSchemaVersionRequired("", &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, "schema version required", errors[0].Message) assert.Equal(t, SchemaVersionRequired, errors[0].Metadata.ErrorType) @@ -286,7 +308,7 @@ func TestErrorCollector_RaiseInvalidSchemaVersion(t *testing.T) { collector.RaiseInvalidSchemaVersion("2.0", &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, "invalid schema 2.0", errors[0].Message) assert.Equal(t, InvalidSchema, errors[0].Metadata.ErrorType) @@ -299,7 +321,7 @@ func TestErrorCollector_RaiseSchemaVersionUnsupported(t *testing.T) { collector.RaiseSchemaVersionUnsupported("1.0", &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, "schema version no longer supported", errors[0].Message) assert.Equal(t, SchemaVersionUnsupported, errors[0].Metadata.ErrorType) @@ -313,7 +335,7 @@ func TestErrorCollector_RaiseUnusedCondition(t *testing.T) { collector.RaiseUnusedCondition("unused_condition", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -325,7 +347,7 @@ func TestErrorCollector_RaiseDifferentNestedConditionName(t *testing.T) { collector.RaiseDifferentNestedConditionName("condition1", "condition2") - errors := collector.GetErrors() + errors := collector.AllFindings() 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) @@ -338,9 +360,10 @@ func TestErrorCollector_RaiseMultipleModulesInSingleFile(t *testing.T) { collector.RaiseMultipleModulesInSingleFile("test.fga", modules) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) - assert.Equal(t, "file 'test.fga' contains multiple modules: module1, module2, module3.", errors[0].Message) + assert.Equal(t, "file test.fga would contain multiple module definitions (module1, module2, module3) "+ + "when transforming to DSL. Only one module can be defined per file.", errors[0].Message) assert.Equal(t, MultipleModulesInFile, errors[0].Metadata.ErrorType) assert.Equal(t, "test.fga", errors[0].Metadata.Symbol) } @@ -358,7 +381,7 @@ func TestErrorCollector_LineAndColumnResolution(t *testing.T) { collector.RaiseInvalidName("viewer", "rule", nil, &lineIndex, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) // Check line information @@ -386,7 +409,7 @@ func TestErrorCollector_CustomResolver(t *testing.T) { collector.RaiseTupleUsersetRequiresDirect("user", "document", "viewer", meta, &lineIndex) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) // The custom resolver should position the error after "from" keyword diff --git a/pkg/go/validation/error_info.go b/pkg/go/validation/error_info.go new file mode 100644 index 00000000..a27bb25d --- /dev/null +++ b/pkg/go/validation/error_info.go @@ -0,0 +1,243 @@ +package validation + +import ( + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// errorInfo is what a code implies beyond its message: its severity, the part of +// the model it belongs to, and the sentinel a caller matches with errors.Is. +type errorInfo struct { + Severity fgaerrors.Severity + Category fgaerrors.ModelErrorKind + Cause error + + // Critical marks a finding that invalidates the model as a whole rather than + // one part of it, so a consumer may stop at the first one. Critical implies + // blocking; TestCriticalImpliesBlocking enforces it. + Critical bool +} + +// errorInfoByType maps every code the validator emits to its severity, category, +// cause and criticality. It is the only place those are decided, so a code cannot +// mean one thing in the collector and another in a report. +// +// Every emitted code must appear here; TestErrorInfoCoversEveryEmittedErrorType +// enforces it, and declared-but-unemitted codes go in unemittedErrorTypes instead. +// +// Category is a default. DuplicatedError covers both a duplicate type and a +// duplicate type restriction, so a raise site overrides it through scope.category. +var errorInfoByType = map[ValidationErrorType]errorInfo{ + // Schema. + InvalidSchema: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindInvalidModel, + Cause: fgaerrors.ErrInvalidSchemaVersion, + Critical: true, + }, + SchemaVersionUnsupported: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindInvalidModel, + Cause: fgaerrors.ErrSchemaVersionUnsupported, + }, + SchemaVersionRequired: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindInvalidModel, + Cause: fgaerrors.ErrSchemaVersionRequired, + }, + + // Naming. + InvalidName: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrInvalidName, + }, + ReservedTypeKeywords: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindObjectType, + Cause: fgaerrors.ErrReservedKeywords, + }, + ReservedRelationKeywords: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrReservedKeywords, + }, + + // Duplicates. + DuplicatedError: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrDuplicateDefinition, + Critical: true, + }, + + // Undefined references. + UndefinedType: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindObjectType, + Cause: fgaerrors.ErrObjectTypeUndefined, + Critical: true, + }, + UndefinedRelation: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrRelationUndefined, + Critical: true, + }, + MissingDefinition: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrRelationUndefined, + }, + + // Types and type restrictions. + InvalidType: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindObjectType, + Cause: fgaerrors.ErrInvalidType, + }, + InvalidRelationType: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrInvalidRelationType, + Critical: true, + }, + AssignableRelationsMustHaveType: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrDirectlyAssignableRelation, + }, + + // Tuplesets. + InvalidRelationOnTupleset: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrInvalidRelationOnTupleset, + }, + TuplesetNotDirect: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrInvalidRelationOnTuplesetNotDirect, + }, + + // Entrypoints. + RelationNoEntrypoint: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrNoEntrypoints, + Critical: true, + }, + + // Wildcards. + InvalidWildcardError: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrInvalidWildcard, + }, + TypeRestrictionCannotHaveWildcardAndRelation: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Cause: fgaerrors.ErrInvalidWildcard, + }, + + // Conditions. + ConditionNotDefined: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelationCondition, + Cause: fgaerrors.ErrConditionUndefined, + }, + ConditionNotUsed: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindCondition, + Cause: fgaerrors.ErrConditionUnReferenced, + }, + DifferentNestedConditionName: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindCondition, + Cause: fgaerrors.ErrConditionNameMismatch, + }, + + // Modules. + MultipleModulesInFile: { + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindInvalidModel, + Cause: fgaerrors.ErrMultipleModulesInFile, + Critical: true, + }, +} + +// unemittedErrorTypes are declared ValidationErrorType values that no validation +// produces, established by inspecting every errorType argument reaching addError. +// +// They are kept rather than deleted because each has a published documentation +// page, and because SelfError and InvalidSyntax are equally unemitted in +// pkg/js/errors.ts. A cycle with no entrypoint surfaces as RelationNoEntrypoint, +// leaving CyclicError and CyclicRelation nothing to report. InvalidSchemaVersion is +// unreachable because RaiseInvalidSchemaVersion emits InvalidSchema, which is what +// the shared corpus expects. +// +// None get an errorInfoByType entry, so lookupErrorInfo treats them as blocking +// with no cause. Anything that starts emitting one must add it to the table in the +// same change. +var unemittedErrorTypes = map[ValidationErrorType]struct{}{ + SelfError: {}, + InvalidSyntax: {}, + CyclicError: {}, + CyclicRelation: {}, + InvalidSchemaVersion: {}, +} + +// allErrorTypes lists every declared ValidationErrorType. A Go const block of a +// string type cannot be enumerated at runtime, so exhaustiveness checks need it +// written out. +// +// Keep in sync with the const block in errors.go. +var allErrorTypes = []ValidationErrorType{ + SchemaVersionRequired, + SchemaVersionUnsupported, + ReservedTypeKeywords, + ReservedRelationKeywords, + SelfError, + InvalidName, + MissingDefinition, + InvalidRelationType, + InvalidRelationOnTupleset, + InvalidType, + RelationNoEntrypoint, + TuplesetNotDirect, + DuplicatedError, + UndefinedType, + UndefinedRelation, + CyclicError, + InvalidWildcardError, + AssignableRelationsMustHaveType, + InvalidSchema, + InvalidSyntax, + TypeRestrictionCannotHaveWildcardAndRelation, + ConditionNotDefined, + ConditionNotUsed, + DifferentNestedConditionName, + MultipleModulesInFile, + CyclicRelation, + InvalidSchemaVersion, +} + +// isCriticalErrorType reports whether a code invalidates the model as a whole. +// Criticality is a field on the errorInfo entry, so a code cannot be critical and +// non-blocking at once. Unknown codes are blocking but not critical. +func isCriticalErrorType(errorType ValidationErrorType) bool { + return lookupErrorInfo(errorType).Critical +} + +// lookupErrorInfo returns the entry for a code. +// +// Unknown and unemitted codes fall back to a blocking error with no cause, so a +// code missing from the table cannot downgrade a finding to non-blocking. +func lookupErrorInfo(errorType ValidationErrorType) errorInfo { + if entry, ok := errorInfoByType[errorType]; ok { + return entry + } + return errorInfo{ + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindInvalidModel, + } +} diff --git a/pkg/go/validation/error_info_integration_test.go b/pkg/go/validation/error_info_integration_test.go new file mode 100644 index 00000000..18cea593 --- /dev/null +++ b/pkg/go/validation/error_info_integration_test.go @@ -0,0 +1,335 @@ +package validation + +import ( + "errors" + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" + "github.com/openfga/language/pkg/go/transformer" +) + +// modelFromDSL parses a DSL string, failing the test if it does not parse: these +// tests are about semantic validation, so a syntax error in a fixture is a bug in +// the test rather than a result. +func modelFromDSL(t *testing.T, dsl string) *openfgav1.AuthorizationModel { + t.Helper() + + model, err := transformer.TransformDSLToProto(dsl) + require.NoError(t, err, "test DSL must parse; this test is about semantic validation") + + return model +} + +// validateDSL runs the full validation path on a DSL string, as a consumer would, +// and recovers the collection behind the returned error. +func validateDSL(t *testing.T, dsl string) *ValidationErrors { + t.Helper() + + return findingsFrom(ValidateDSL(modelFromDSL(t, dsl), dsl, DefaultEngineOptions())) +} + +// TestErrorsIsThroughValidation checks errors.Is against findings from real +// validation, so a caller can identify what went wrong without matching message +// text. +func TestErrorsIsThroughValidation(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + dsl string + wantSentinel error + wantCategory fgaerrors.ModelErrorKind + wantScope func(t *testing.T, err error) + }{ + "undefined type in restriction": { + dsl: `model + schema 1.1 +type document + relations + define viewer: [user] +`, + wantSentinel: fgaerrors.ErrInvalidType, + wantCategory: fgaerrors.ErrorKindObjectType, + wantScope: func(t *testing.T, err error) { + t.Helper() + + var scoped *fgaerrors.ErrObjectType + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "user", scoped.ObjectType) + }, + }, + "relation with no entrypoint": { + dsl: `model + schema 1.1 +type user +type document + relations + define viewer: writer + define writer: viewer +`, + wantSentinel: fgaerrors.ErrNoEntrypoints, + wantCategory: fgaerrors.ErrorKindRelation, + wantScope: func(t *testing.T, err error) { + t.Helper() + + var scoped *fgaerrors.ErrRelation + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "document", scoped.ObjectType) + assert.NotEmpty(t, scoped.Relation) + }, + }, + "duplicate type": { + dsl: `model + schema 1.1 +type user +type document +type document +`, + wantSentinel: fgaerrors.ErrDuplicateDefinition, + wantCategory: fgaerrors.ErrorKindObjectType, + wantScope: func(t *testing.T, err error) { + t.Helper() + + // An ErrObjectType has no Relation field, so a duplicate type + // cannot arrive carrying one. + var scoped *fgaerrors.ErrObjectType + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "document", scoped.ObjectType) + }, + }, + "condition defined but unused": { + dsl: `model + schema 1.1 +type user +type document + relations + define viewer: [user] + +condition inRegion(x: string) { + x == "eu" +} +`, + wantSentinel: fgaerrors.ErrConditionUnReferenced, + wantCategory: fgaerrors.ErrorKindCondition, + wantScope: func(t *testing.T, err error) { + t.Helper() + + // A condition definition is not scoped to a type, and + // ErrCondition has no field for one. + var scoped *fgaerrors.ErrCondition + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "inRegion", scoped.Condition) + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + validationErrors := validateDSL(t, test.dsl) + require.NotNil(t, validationErrors) + require.True(t, validationErrors.HasErrors(), "expected this model to fail validation") + + // Every finding, so a severity this test does not expect fails on the + // severity assertion below rather than by going missing here. + var matched *ValidationError + for _, candidate := range validationErrors.AllFindings() { + if errors.Is(candidate, test.wantSentinel) { + matched = candidate + + break + } + } + + require.NotNilf(t, matched, + "no finding matched %v via errors.Is; got %v", test.wantSentinel, validationErrors.Error()) + + assert.Equal(t, test.wantCategory, matched.Category) + assert.Equal(t, fgaerrors.SeverityError, matched.Severity) + assert.True(t, matched.Blocks()) + + require.Error(t, matched.Unwrap(), "errors.As must have a scoped cause to reach") + test.wantScope(t, error(matched)) + }) + } +} + +// TestMetadataIsDerivedFromCause checks the serialised metadata and the errors.As +// payload describe the same scope, so the two cannot drift. +func TestMetadataIsDerivedFromCause(t *testing.T) { + t.Parallel() + + validationErrors := validateDSL(t, `model + schema 1.1 +type user +type document + relations + define viewer: writer + define writer: viewer +`) + require.True(t, validationErrors.HasErrors()) + + checked := 0 + + for _, validationErr := range validationErrors.AllFindings() { + if validationErr.Unwrap() == nil { + continue + } + + objectType, relation, condition := causeScope(error(validationErr)) + + require.NotNil(t, validationErr.Metadata) + assert.Equal(t, objectType, validationErr.Metadata.Type, + "metadata type must match the cause it was derived from") + assert.Equal(t, relation, validationErr.Metadata.Relation) + assert.Equal(t, condition, validationErr.Metadata.Condition) + + checked++ + } + + assert.Positive(t, checked, "no finding carried a scoped cause; the derivation was not exercised") +} + +// TestEverySemanticFindingCarriesErrorInfo sweeps a range of broken models and +// asserts no finding escapes without severity, category and a matchable cause. +// A gap here means some code path bypasses the table. +func TestEverySemanticFindingCarriesErrorInfo(t *testing.T) { + t.Parallel() + + models := []string{ + `model + schema 1.1 +type document + relations + define viewer: [user] +`, + `model + schema 1.1 +type user +type document +type document +`, + `model + schema 1.1 +type user +type document + relations + define viewer: writer + define writer: viewer +`, + `model + schema 1.1 +type user +type document + relations + define viewer: [user] + +condition inRegion(x: string) { + x == "eu" +} +`, + `model + schema 1.1 +type user +type document + relations + define parent: [document] + define viewer: viewer from parent +`, + } + + total := 0 + + for index, dsl := range models { + model, err := transformer.TransformDSLToProto(dsl) + + // Not skipped: a model that stops parsing drops silently out of the sweep, + // and the total below would still pass on the models that remain. + require.NoErrorf(t, err, "model %d no longer parses", index) + + validationErrors := findingsFrom(ValidateDSL(model, dsl, DefaultEngineOptions())) + + for _, validationErr := range validationErrors.AllFindings() { + total++ + + require.NotNilf(t, validationErr.Metadata, "model %d: finding without metadata", index) + + errorType := validationErr.Metadata.ErrorType + + assert.NotEmptyf(t, validationErr.Severity, + "model %d: %q has no severity", index, errorType) + + if _, classified := errorInfoByType[errorType]; classified { + require.Errorf(t, validationErr.Unwrap(), + "model %d: %q is in the errorInfoByType but carries no cause", index, errorType) + } + } + } + + assert.Positive(t, total, "no findings produced; this test asserted nothing") +} + +// TestNonBlockingTableEntryReachesTheCaller closes the gap the other severity tests +// leave: they assert what errorInfoByType holds, or build findings by hand, and every +// entry is SeverityError today, so nothing follows a non-blocking severity from the +// table through addScopedError and out of an entry point. This downgrades one entry and +// does exactly that. +// +// It must not call t.Parallel: it mutates errorInfoByType, and Go runs a sequential +// test only with other sequential tests. +func TestNonBlockingTableEntryReachesTheCaller(t *testing.T) { + original := errorInfoByType[InvalidName] + downgraded := original + downgraded.Severity = fgaerrors.SeverityWarning + errorInfoByType[InvalidName] = downgraded + + t.Cleanup(func() { errorInfoByType[InvalidName] = original }) + + // A name the DSL parser would reject, so the model is built as the proto a JSON + // caller would supply. + model := &openfgav1.AuthorizationModel{ + SchemaVersion: "1.1", + TypeDefinitions: []*openfgav1.TypeDefinition{{Type: "Bad Type Name"}}, + } + + require.NoError(t, ValidateModelJSON(model), + "a model whose only finding is a warning is valid, so the entry point returns nil") + + engine := NewValidationEngine(model, "") + collection := engine.RunAllValidations(DefaultEngineOptions()) + + require.Equal(t, 1, collection.CountAll(), + "this model must raise exactly one finding, or the counts below are ambiguous") + assert.Equal(t, 0, collection.Count()) + assert.False(t, collection.HasErrors()) + assert.True(t, collection.HasFindings()) + assert.Empty(t, collection.GetErrors()) + require.NoError(t, collection.ErrorOrNil()) + + finding := collection.AllFindings()[0] + assert.Equal(t, fgaerrors.SeverityWarning, finding.Severity, "the severity came from the table") + assert.False(t, finding.Blocks()) + + // Severity is independent of the cause: a warning still carries its sentinel and + // its scope. + require.ErrorIs(t, error(finding), fgaerrors.ErrInvalidName) + + var scoped *fgaerrors.ErrObjectType + require.ErrorAs(t, error(finding), &scoped) + assert.Equal(t, "Bad Type Name", scoped.ObjectType) + + summary := engine.GetValidationSummary() + assert.Equal(t, 0, summary.TotalErrors) + assert.Equal(t, 1, summary.TotalFindings) + assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityWarning]) + assert.False(t, summary.HasCriticalErrors) + + report := CreateValidationReport(model, "", DefaultEngineOptions()) + assert.True(t, report.IsValid(), "a warning does not invalidate the model") + assert.Len(t, report.GetErrorsByType(InvalidName), 1, + "GetErrorsByType names a code, so it returns the finding whatever its severity") +} diff --git a/pkg/go/validation/error_info_test.go b/pkg/go/validation/error_info_test.go new file mode 100644 index 00000000..59dd7bcf --- /dev/null +++ b/pkg/go/validation/error_info_test.go @@ -0,0 +1,314 @@ +package validation + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// emittedErrorTypes parses this package's non-test sources and returns the name of +// every ValidationErrorType passed as the errorType argument of an addError call. It +// reads the source rather than a hand-written list, which would go stale in the same +// edit that leaves a code out of the table. +func emittedErrorTypes(t *testing.T) map[string]string { + t.Helper() + + entries, err := os.ReadDir(".") + require.NoError(t, err) + + emitted := make(map[string]string) + fileSet := token.NewFileSet() + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + + file, err := parser.ParseFile(fileSet, name, nil, parser.SkipObjectResolution) + require.NoError(t, err, "parsing %s", name) + + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + + // Looking for c.addError(message, , ...) and its scoped + // variant. Both, because a raise site that gains scope moves from one + // to the other. + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || len(call.Args) < 2 { + return true + } + + if selector.Sel.Name != "addError" && selector.Sel.Name != "addScopedError" { + return true + } + + identifier, ok := call.Args[1].(*ast.Ident) + + // addError forwards its own errorType parameter to addScopedError: + // plumbing, not a raise site. + if ok && identifier.Name == "errorType" { + return true + } + + if !ok { + // A non-identifier errorType means the emitted set can't be + // determined statically, and this test would silently under-report. + t.Errorf("%s: addError called with a non-constant errorType at %s; "+ + "emittedErrorTypes can no longer see what this emits", + name, fileSet.Position(call.Args[1].Pos())) + + return true + } + + emitted[identifier.Name] = fileSet.Position(call.Pos()).String() + + return true + }) + } + + return emitted +} + +// TestErrorInfoCoversEveryEmittedErrorType checks every code a Raise* method can +// emit has a table entry, so any finding that reaches a caller has a cause to +// match. It fails when a new Raise* method is added without one. +func TestErrorInfoCoversEveryEmittedErrorType(t *testing.T) { + t.Parallel() + + emitted := emittedErrorTypes(t) + require.NotEmpty(t, emitted, "found no addError calls — the AST walk is broken, not the errorInfoByType") + + // Names, because the AST gives us identifiers and the table is keyed by value. + classifiedNames := make(map[string]struct{}, len(errorInfoByType)) + for errorType := range errorInfoByType { + classifiedNames[errorTypeConstantName(t, errorType)] = struct{}{} + } + + for name, position := range emitted { + if _, ok := classifiedNames[name]; !ok { + t.Errorf("%s is emitted at %s but has no errorInfoByType entry: "+ + "callers cannot match its cause with errors.Is", name, position) + } + } +} + +// TestErrorInfoHasNoUnemittedEntries checks the other direction: an entry for a code +// nothing raises hides that the code is dead. +func TestErrorInfoHasNoUnemittedEntries(t *testing.T) { + t.Parallel() + + emitted := emittedErrorTypes(t) + + for errorType := range errorInfoByType { + name := errorTypeConstantName(t, errorType) + if _, ok := emitted[name]; !ok { + t.Errorf("errorInfoByType has an entry for %s (%q) but nothing emits it — "+ + "either wire up the Raise* method or move it to unemittedErrorTypes", + name, errorType) + } + } +} + +// TestEveryErrorTypeIsClassified checks a newly declared error type cannot sit in +// neither map. Adding a constant and forgetting the table passes every other test +// in this file. +func TestEveryErrorTypeIsClassified(t *testing.T) { + t.Parallel() + + for _, errorType := range allErrorTypes { + _, inErrorInfo := errorInfoByType[errorType] + _, unemitted := unemittedErrorTypes[errorType] + + assert.Truef(t, inErrorInfo || unemitted, + "%q appears in neither errorInfoByType nor unemittedErrorTypes; "+ + "classify it as one or the other", errorType) + assert.Falsef(t, inErrorInfo && unemitted, + "%q is in both errorInfoByType and unemittedErrorTypes", errorType) + } +} + +// TestAllErrorTypesIsComplete checks the hand-written allErrorTypes list the two +// tests above depend on, by reading the const block it mirrors. +func TestAllErrorTypesIsComplete(t *testing.T) { + t.Parallel() + + declared := declaredErrorTypeValues(t) + + listed := make(map[ValidationErrorType]struct{}, len(allErrorTypes)) + for _, errorType := range allErrorTypes { + _, duplicate := listed[errorType] + assert.Falsef(t, duplicate, "%q is listed twice in allErrorTypes", errorType) + listed[errorType] = struct{}{} + } + + for name, value := range declared { + _, ok := listed[value] + assert.Truef(t, ok, "%s (%q) is declared in errors.go but missing from allErrorTypes", name, value) + } + + assert.Len(t, allErrorTypes, len(declared), + "allErrorTypes has %d entries but %d ValidationErrorType constants are declared", + len(allErrorTypes), len(declared)) +} + +// TestErrorInfoEntriesAreWellFormed checks each entry says something usable: a +// severity that exists, a category that serialises, and a non-nil cause. +func TestErrorInfoEntriesAreWellFormed(t *testing.T) { + t.Parallel() + + validSeverities := map[fgaerrors.Severity]struct{}{ + fgaerrors.SeverityError: {}, + fgaerrors.SeverityWarning: {}, + fgaerrors.SeverityAdvisory: {}, + } + + validCategories := map[fgaerrors.ModelErrorKind]struct{}{ + fgaerrors.ErrorKindObjectType: {}, + fgaerrors.ErrorKindRelation: {}, + fgaerrors.ErrorKindRelationCondition: {}, + fgaerrors.ErrorKindCondition: {}, + fgaerrors.ErrorKindInvalidModel: {}, + } + + for errorType, entry := range errorInfoByType { + t.Run(string(errorType), func(t *testing.T) { + t.Parallel() + + _, ok := validSeverities[entry.Severity] + assert.Truef(t, ok, "severity %q is not one of error/warning/advisory", entry.Severity) + + // A typo here would silently mint a new wire name. + _, ok = validCategories[entry.Category] + assert.Truef(t, ok, "category %q is not a declared ModelErrorKind", entry.Category) + + assert.Error(t, entry.Cause, "no cause: errors.Is has nothing to match against") + }) + } +} + +// TestEveryEntryBlocks pins what the validation entry points currently rely on: +// every classified code blocks, so Count equals CountAll and a model with any +// finding at all returns non-nil from ValidateDSL. +// +// This is a tripwire rather than a rule. The first non-blocking entry is a +// deliberate change, and it needs ValidationErrors.ErrorOrNil settled in the same +// edit: a collection holding only warnings answers nil there, so that finding never +// reaches a caller through the entry points at all. Either route it through +// CreateValidationReport or change what the entry points return, then update this +// test. +func TestEveryEntryBlocks(t *testing.T) { + t.Parallel() + + for errorType, entry := range errorInfoByType { + assert.Truef(t, entry.Severity.Blocks(), + "%q is classified %s, the first non-blocking code in the table: a model whose "+ + "only finding is this one is valid as far as ValidateDSL is concerned", + errorType, entry.Severity) + } +} + +// TestLookupErrorInfoFallsBackToBlocking checks an unclassified finding still fails +// validation. Downgrading it to advisory would let an invalid model through. +func TestLookupErrorInfoFallsBackToBlocking(t *testing.T) { + t.Parallel() + + entry := lookupErrorInfo(ValidationErrorType("no-such-error-type")) + + assert.Equal(t, fgaerrors.SeverityError, entry.Severity) + assert.True(t, entry.Severity.Blocks(), "an unknown error type must still block validation") + assert.NoError(t, entry.Cause, "an unknown error type has no cause to report") +} + +// TestEveryErrorTypeHasDocumentation keeps the slugs and docs/validation/model in +// step. The slug is what a user sees, so one with no page is a dead end. +func TestEveryErrorTypeHasDocumentation(t *testing.T) { + t.Parallel() + + docsDir := filepath.Join("..", "..", "..", "docs", "validation", "model") + if _, err := os.Stat(docsDir); os.IsNotExist(err) { + t.Skipf("docs directory not present at %s", docsDir) + } + + for _, errorType := range allErrorTypes { + page := filepath.Join(docsDir, string(errorType)+".md") + _, err := os.Stat(page) + assert.NoErrorf(t, err, "%q has no documentation page at %s", errorType, page) + } +} + +// errorTypeConstantName maps a slug back to its Go constant name, so failures name +// the identifier to edit rather than the string. +func errorTypeConstantName(t *testing.T, errorType ValidationErrorType) string { + t.Helper() + + for name, value := range declaredErrorTypeValues(t) { + if value == errorType { + return name + } + } + + t.Fatalf("%q is not a declared ValidationErrorType constant", errorType) + + return "" +} + +// declaredErrorTypeValues parses errors.go and returns every declared +// ValidationErrorType constant as name → value. +func declaredErrorTypeValues(t *testing.T) map[string]ValidationErrorType { + t.Helper() + + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, "errors.go", nil, parser.SkipObjectResolution) + require.NoError(t, err) + + declared := make(map[string]ValidationErrorType) + + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.CONST { + continue + } + + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + typeIdent, ok := valueSpec.Type.(*ast.Ident) + if !ok || typeIdent.Name != "ValidationErrorType" { + continue + } + + for i, name := range valueSpec.Names { + require.Lessf(t, i, len(valueSpec.Values), "%s has no value", name.Name) + + literal, ok := valueSpec.Values[i].(*ast.BasicLit) + require.Truef(t, ok, "%s is not assigned a string literal", name.Name) + + value, err := strconv.Unquote(literal.Value) + require.NoError(t, err) + + declared[name.Name] = ValidationErrorType(value) + } + } + } + + require.NotEmpty(t, declared, "parsed no ValidationErrorType constants from errors.go") + + return declared +} diff --git a/pkg/go/validation/errors.go b/pkg/go/validation/errors.go index 213467d1..08387792 100644 --- a/pkg/go/validation/errors.go +++ b/pkg/go/validation/errors.go @@ -2,7 +2,10 @@ package validation import ( "fmt" + "slices" "strings" + + fgaerrors "github.com/openfga/language/pkg/go/errors" ) // ValidationErrorType represents the different types of validation errors. @@ -43,14 +46,13 @@ const ( 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 { +// 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"` } @@ -68,11 +70,27 @@ type ErrorMetadata struct { // ValidationError represents a single validation error. type ValidationError struct { - Message string `json:"msg"` - Line *LineRange `json:"line,omitempty"` - Column *ColumnRange `json:"column,omitempty"` + Message string `json:"msg"` + + // Severity states whether this finding makes the model invalid. Findings that + // do not block are reported without failing validation. + Severity fgaerrors.Severity `json:"severity,omitempty"` + + // Category is the part of the model this finding is about. + Category fgaerrors.ModelErrorKind `json:"category,omitempty"` + + Line *Range `json:"line,omitempty"` + Column *Range `json:"column,omitempty"` File string `json:"file,omitempty"` Metadata *ErrorMetadata `json:"metadata,omitempty"` + + // Cause is the scoped error this finding wraps, and what Unwrap returns: + // errors.Is identifies the condition, errors.As the part of the model. + // + // It stays off the wire because an error field has no concrete type to decode + // into, which would leave ValidationError unable to round-trip. The message, + // severity and metadata carry the same information in JSON. + Cause error `json:"-"` } // Error implements the error interface. @@ -84,6 +102,23 @@ func (e *ValidationError) Error() string { return fmt.Sprintf("validation error%s: %s", location, e.Message) } +// Unwrap returns Cause, which is nil for an error built directly rather than +// through the collector. +func (e *ValidationError) Unwrap() error { + return e.Cause +} + +// Blocks reports whether this finding makes the model invalid. A directly-constructed +// error has no severity set and blocks; see Severity.Blocks. A nil finding is not a +// finding, so it blocks nothing. +func (e *ValidationError) Blocks() bool { + if e == nil { + return false + } + + return e.Severity.Blocks() +} + // String returns a string representation of the error. func (e *ValidationError) String() string { return e.Error() @@ -93,27 +128,106 @@ func (e *ValidationError) String() string { // //nolint:errname // plural name intentionally describes a collection of errors type ValidationErrors struct { + // Errors holds every finding in the order it was raised, blocking or not. + // len(Errors) is CountAll, not Count; HasErrors and GetErrors are the + // blocking-only views. Errors []*ValidationError `json:"errors"` } +// findings is the slice every read method below goes through, so a nil collection is +// an empty one in one place rather than in nine. A nil *ValidationErrors reaches these +// methods through a zero ValidationReport, among other paths. +// +// A nil *ValidationError is dropped, because it is not a finding: counting one would +// have CountAll and HasFindings disagree with Blocks and Unwrap, and would put an +// entry in the slice AllFindings hands out that dereferences nil on Severity or +// String. The collector never appends one; a collection built through +// NewValidationErrors, Add or the exported Errors field can hold one. +// +// The scan returns the slice untouched when there is nothing to drop, so the usual +// case does not allocate. +func (e *ValidationErrors) findings() []*ValidationError { + if e == nil { + return nil + } + + if !slices.Contains(e.Errors, nil) { + return e.Errors + } + + held := make([]*ValidationError, 0, len(e.Errors)) + + for _, err := range e.Errors { + if err != nil { + held = append(held, err) + } + } + + return held +} + // Error implements the error interface for ValidationErrors. +// +// It reports the blocking findings only, so the count in the message agrees with +// Count. func (e *ValidationErrors) Error() string { - if len(e.Errors) == 0 { + blocking := e.GetErrors() + if len(blocking) == 0 { return "no validation errors" } plural := "" - if len(e.Errors) > 1 { + if len(blocking) > 1 { plural = "s" } var errorStrings []string - for _, err := range e.Errors { + for _, err := range blocking { 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* ")) + len(blocking), plural, strings.Join(errorStrings, "\n\t* ")) +} + +// Unwrap returns every finding, so errors.Is and errors.As reach each sentinel and +// scope through the collection. Non-blocking findings are included, since errors.Is +// asks whether a condition was reported, not whether it blocks. +// +// Because errors.As stops at the first match, enumerating every finding of one +// scope means walking AllFindings. +func (e *ValidationErrors) Unwrap() []error { + held := e.findings() + if len(held) == 0 { + return nil + } + + // findings drops nil entries, so none reaches errors.Is here: handing it a nil + // *ValidationError as a non-nil error panics. + unwrapped := make([]error, 0, len(held)) + for _, err := range held { + unwrapped = append(unwrapped, err) + } + + return unwrapped +} + +// ErrorOrNil returns e as an error, or nil when no finding blocks. +// +// The validation entry points return this, so err != nil means the model is invalid +// rather than that something was reported: a model whose only findings are warnings +// or advisories yields nil. Non-blocking findings alongside a blocking one stay +// reachable through errors.As and AllFindings. +// +// Findings from a model that stays valid are only reachable off this path: +// CreateValidationReport returns the collection itself, and AllFindings on it lists +// everything raised. +func (e *ValidationErrors) ErrorOrNil() error { + if !e.HasErrors() { + return nil + } + + return e } // Add adds a validation error to the collection. @@ -121,12 +235,28 @@ func (e *ValidationErrors) Add(err *ValidationError) { e.Errors = append(e.Errors, err) } -// GetErrors returns a slice of all validation errors. +// GetErrors returns the findings that make the model invalid. +// +// Non-blocking findings are excluded; AllFindings returns everything. func (e *ValidationErrors) GetErrors() []*ValidationError { - return e.Errors + held := e.findings() + + blocking := make([]*ValidationError, 0, len(held)) + for _, err := range held { + if err.Blocks() { + blocking = append(blocking, err) + } + } + return blocking +} + +// AllFindings returns every finding, blocking or not, in the order raised. Each +// finding's Severity says how to present it. +func (e *ValidationErrors) AllFindings() []*ValidationError { + return e.findings() } -// NewValidationErrors creates a new ValidationErrors instance from a slice of ValidationError +// NewValidationErrors creates a new ValidationErrors instance from a slice of ValidationError. func NewValidationErrors(errors []*ValidationError) *ValidationErrors { if errors == nil { errors = make([]*ValidationError, 0) @@ -136,14 +266,37 @@ func NewValidationErrors(errors []*ValidationError) *ValidationErrors { } } -// HasErrors returns true if there are any errors. +// HasErrors reports whether any finding makes the model invalid. A model with only +// warnings or advisories is valid, so this is false; HasFindings covers everything +// raised. func (e *ValidationErrors) HasErrors() bool { - return len(e.Errors) > 0 + for _, err := range e.findings() { + if err.Blocks() { + return true + } + } + return false +} + +// HasFindings reports whether anything at all was reported, blocking or not. +func (e *ValidationErrors) HasFindings() bool { + return len(e.findings()) > 0 } -// Count returns the number of errors. +// Count returns the number of findings that make the model invalid. func (e *ValidationErrors) Count() int { - return len(e.Errors) + count := 0 + for _, err := range e.findings() { + if err.Blocks() { + count++ + } + } + return count +} + +// CountAll returns the total number of findings, blocking or not. +func (e *ValidationErrors) CountAll() int { + return len(e.findings()) } // Meta represents file and module metadata. diff --git a/pkg/go/validation/errors_test.go b/pkg/go/validation/errors_test.go index c558d32e..a3fd01f7 100644 --- a/pkg/go/validation/errors_test.go +++ b/pkg/go/validation/errors_test.go @@ -1,9 +1,13 @@ package validation import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" ) func TestValidationError_Error(t *testing.T) { @@ -16,8 +20,8 @@ func TestValidationError_Error(t *testing.T) { name: "error with line and column", error: &ValidationError{ Message: "test error message", - Line: &LineRange{Start: 5, End: 5}, - Column: &ColumnRange{Start: 10, End: 15}, + Line: &Range{Start: 5, End: 5}, + Column: &Range{Start: 10, End: 15}, Metadata: &ErrorMetadata{ Symbol: "test_symbol", ErrorType: InvalidName, @@ -41,8 +45,8 @@ func TestValidationError_Error(t *testing.T) { error: &ValidationError{ Message: "test error message", File: "test.fga", - Line: &LineRange{Start: 3, End: 3}, - Column: &ColumnRange{Start: 0, End: 4}, + Line: &Range{Start: 3, End: 3}, + Column: &Range{Start: 0, End: 4}, }, expected: "validation error at line=3, column=0: test error message", }, @@ -59,8 +63,8 @@ func TestValidationError_Error(t *testing.T) { func TestValidationError_String(t *testing.T) { valErr := &ValidationError{ Message: "test error", - Line: &LineRange{Start: 1, End: 1}, - Column: &ColumnRange{Start: 5, End: 10}, + Line: &Range{Start: 1, End: 1}, + Column: &Range{Start: 5, End: 10}, } // String() should return the same as Error() @@ -170,13 +174,13 @@ func TestErrorMetadata(t *testing.T) { } func TestLineRange(t *testing.T) { - line := &LineRange{Start: 5, End: 10} + line := &Range{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} + column := &Range{Start: 15, End: 25} assert.Equal(t, 15, column.Start) assert.Equal(t, 25, column.End) } @@ -229,3 +233,138 @@ func TestMeta(t *testing.T) { assert.Equal(t, "test.fga", meta.File) assert.Equal(t, "test_module", meta.Module) } + +// TestCategorySerialisesForEveryCategory checks every finding carries its category +// on the wire under its name. Category counts from iota + 1, so no real category is +// the zero value omitempty drops. +func TestCategorySerialisesForEveryCategory(t *testing.T) { + t.Parallel() + + collector := NewErrorCollector(nil) + collector.RaiseInvalidType("user", "document", "viewer", nil, nil) // object-type + collector.RaiseDuplicateTypeRestriction("user", "viewer", "document", nil, nil) // relation + collector.RaiseUnusedCondition("unused_cond", nil, nil) // condition + + wantCategories := []string{`"category":"object-type"`, `"category":"relation"`, `"category":"condition"`} + + findings := collector.AllFindings() + require.Len(t, findings, len(wantCategories)) + + for i, want := range wantCategories { + encoded, err := json.Marshal(findings[i]) + require.NoError(t, err) + assert.Containsf(t, string(encoded), want, + "finding %d (%s) must carry its category on the wire", i, findings[i].Metadata.ErrorType) + } +} + +// TestSeveritySerialisesUnderItsName checks the same for severity, and that a +// finding which never set one carries no severity field at all. +func TestSeveritySerialisesUnderItsName(t *testing.T) { + t.Parallel() + + collector := NewErrorCollector(nil) + collector.RaiseInvalidType("user", "document", "viewer", nil, nil) + + findings := collector.AllFindings() + require.Len(t, findings, 1) + + encoded, err := json.Marshal(findings[0]) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"severity":"error"`, + "a classified finding must carry its severity on the wire under its name") + + encoded, err = json.Marshal(&ValidationError{Message: "built without the collector"}) + require.NoError(t, err) + assert.NotContains(t, string(encoded), `"severity"`, + "an unclassified finding must omit severity rather than report one it never set") +} + +// TestValidationErrorWireShape checks the serialised document of a finding with +// every field set. +// +// The key names are the cross-language contract: pkg/js and pkg/java agree with Go +// on msg, line, column and metadata.symbol/errorType, and +// tests/data/dsl-semantic-validation-cases.yaml is written in them. Nothing else in +// this package marshals a finding, so a renamed json tag would otherwise change +// every consumer's output without failing a test. JSONEq compares the whole +// document, so an added key fails here too. +func TestValidationErrorWireShape(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(&ValidationError{ + Message: "the relation 'allowed' does not exist.", + Severity: fgaerrors.SeverityError, + Category: fgaerrors.ErrorKindRelation, + Line: &Range{Start: 6, End: 6}, + Column: &Range{Start: 41, End: 48}, + File: "model.fga", + Metadata: &ErrorMetadata{ + Symbol: "allowed", + ErrorType: MissingDefinition, + Module: "core", + Type: "document", + Relation: "reader", + Condition: "inRegion", + OffendingType: "folder", + }, + }) + require.NoError(t, err) + + assert.JSONEq(t, `{ + "msg": "the relation 'allowed' does not exist.", + "severity": "error", + "category": "relation", + "line": {"start": 6, "end": 6}, + "column": {"start": 41, "end": 48}, + "file": "model.fga", + "metadata": { + "symbol": "allowed", + "errorType": "missing-definition", + "module": "core", + "type": "document", + "relation": "reader", + "condition": "inRegion", + "offendingType": "folder" + } + }`, string(encoded)) +} + +// TestValidationErrorWireShapeOmitsUnsetFields checks a finding with nothing but a +// message and the two mandatory metadata fields emits no keys for scope it does not +// have. A consumer tells "no condition" from "condition is empty" by the key's +// absence. +func TestValidationErrorWireShapeOmitsUnsetFields(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(&ValidationError{ + Message: "schema version required", + Metadata: &ErrorMetadata{Symbol: "schema", ErrorType: SchemaVersionRequired}, + }) + require.NoError(t, err) + + assert.JSONEq(t, `{ + "msg": "schema version required", + "metadata": {"symbol": "schema", "errorType": "schema-version-required"} + }`, string(encoded)) +} + +// TestValidationErrorsWireShape checks the envelope, which is what a consumer +// decodes first. +func TestValidationErrorsWireShape(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(NewValidationErrors(nil)) + require.NoError(t, err) + assert.JSONEq(t, `{"errors": []}`, string(encoded), + "no findings must serialise as an empty list, not null") + + encoded, err = json.Marshal(NewValidationErrors([]*ValidationError{{ + Message: "x", + Metadata: &ErrorMetadata{Symbol: "s", ErrorType: InvalidType}, + }})) + require.NoError(t, err) + assert.JSONEq(t, + `{"errors": [{"msg": "x", "metadata": {"symbol": "s", "errorType": "invalid-type"}}]}`, + string(encoded)) +} diff --git a/pkg/go/validation/json_corpus_test.go b/pkg/go/validation/json_corpus_test.go new file mode 100644 index 00000000..13f3065c --- /dev/null +++ b/pkg/go/validation/json_corpus_test.go @@ -0,0 +1,64 @@ +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 both consume this file and nothing in Go read it, so a rule that only a +// JSON model can reach was pinned in the other two SDKs and free to drift here. +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, + findingsFrom(ValidateJSON(model, DefaultEngineOptions()))) + + for _, problem := range result.Problems { + t.Error(problem) + } + + require.Equal(t, corpusPass, result.Status) + }) + } +} diff --git a/pkg/go/validation/keywords_test.go b/pkg/go/validation/keywords_test.go index ff92a8cf..ee42bc6d 100644 --- a/pkg/go/validation/keywords_test.go +++ b/pkg/go/validation/keywords_test.go @@ -276,23 +276,23 @@ func TestReservedKeywordsValidation(t *testing.T) { // Test type name validation - should pass for valid names isValid := ValidateTypeName("document", collector, nil, nil) assert.True(t, isValid) - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, collector.AllFindings()) // 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()) + assert.NotEmpty(t, collector.AllFindings()) // 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()) + assert.Empty(t, collector.AllFindings()) // 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()) + assert.NotEmpty(t, collector.AllFindings()) } diff --git a/pkg/go/validation/multi_file_validation.go b/pkg/go/validation/multi_file_validation.go index 700acdc1..4ef9c7a6 100644 --- a/pkg/go/validation/multi_file_validation.go +++ b/pkg/go/validation/multi_file_validation.go @@ -1,7 +1,9 @@ package validation import ( + "maps" "path/filepath" + "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -9,12 +11,54 @@ import ( // 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 + fileToModules *orderedGroups + moduleToFiles *orderedGroups typeModuleMap map[string]string conditionModuleMap map[string]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. +// +// Held by pointer: copying the struct copies keys but shares values, so an add +// through the copy leaves the original holding a value under a key it never +// recorded, and iterating keys then drops it. +type orderedGroups struct { + keys []string + values map[string][]string +} + +func newOrderedGroups() *orderedGroups { + return &orderedGroups{values: make(map[string][]string)} +} + +func (g *orderedGroups) add(key, value string) { + existing, seen := g.values[key] + if !seen { + g.keys = append(g.keys, key) + } + + if slices.Contains(existing, value) { + return + } + + g.values[key] = append(existing, value) +} + +// get returns a non-nil copy, so a caller cannot reorder the record it reads. Not +// slices.Clone, which returns nil for an absent key and so would have the accessors +// hand back a slice that marshals as null rather than []. +func (g *orderedGroups) get(key string) []string { + values := make([]string, 0, len(g.values[key])) + + return append(values, g.values[key]...) +} + // ModuleInfo represents information about a module. type ModuleInfo struct { Name string @@ -31,30 +75,64 @@ type FileInfo struct { func NewMultiFileValidator(model *openfgav1.AuthorizationModel) *MultiFileValidator { validator := &MultiFileValidator{ model: model, - fileToModuleMap: make(map[string]map[string]bool), - moduleToFileMap: make(map[string]map[string]bool), + fileToModules: newOrderedGroups(), + moduleToFiles: newOrderedGroups(), typeModuleMap: make(map[string]string), conditionModuleMap: make(map[string]string), } validator.buildFileMappings() + return validator } +// buildFileMappings 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 reported after the module of every type, not after its own +// type's. func (mfv *MultiFileValidator) buildFileMappings() { if mfv.model == nil { 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() { + + for _, typeDef := range mfv.model.GetTypeDefinitions() { + // Relation names arrive in a proto map, which has no order of its own, so + // they are walked in name order. + for _, relation := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { + relationMetadata := typeDef.GetMetadata().GetRelations()[relation] + + // A relation may name its own file and module, and falls back to its + // type's for whichever of the two it leaves unset. + file := relationMetadata.GetSourceInfo().GetFile() + if file == "" { + file = typeDef.GetMetadata().GetSourceInfo().GetFile() + } + + module := relationMetadata.GetModule() + if module == "" { + module = typeDef.GetMetadata().GetModule() + } + + if file != "" && module != "" { + mfv.addFileModuleMapping(file, module) + } + } + } + + for _, conditionName := range slices.Sorted(maps.Keys(mfv.model.GetConditions())) { + condition := mfv.model.GetConditions()[conditionName] file := condition.GetMetadata().GetSourceInfo().GetFile() module := condition.GetMetadata().GetModule() + if file != "" && module != "" { mfv.addFileModuleMapping(file, module) mfv.conditionModuleMap[conditionName] = module @@ -64,14 +142,8 @@ func (mfv *MultiFileValidator) buildFileMappings() { 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 + mfv.fileToModules.add(file, module) + mfv.moduleToFiles.add(module, file) } // ValidateMultiFileConsistency validates consistency across multiple files. @@ -79,74 +151,60 @@ func ValidateMultiFileConsistency(collector *ErrorCollector, model *openfgav1.Au if model == nil { return } - validator := NewMultiFileValidator(model) - validator.validateMultipleModulesInFile(collector) -} - -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) - } - } + // The rule itself lives in ValidateMultipleModulesInFile, which takes the files + // this validator collected; the two must not drift. + ValidateMultipleModulesInFile(collector, NewMultiFileValidator(model).GetFileInfo()) } 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 := make([]ModuleInfo, 0, len(mfv.moduleToFiles.keys)) + + for _, moduleName := range mfv.moduleToFiles.keys { + info := ModuleInfo{Name: moduleName, Files: mfv.moduleToFiles.get(moduleName), Types: make([]string, 0)} + + // Declaration order: ranging typeModuleMap would list the types in whatever + // order the runtime handed back. A name the model declares twice is reached + // once per declaration, and typeModuleMap resolves it to a single module, so + // each name is listed once rather than once per declaration. + for _, typeDef := range mfv.model.GetTypeDefinitions() { + typeName := typeDef.GetType() + + if mfv.typeModuleMap[typeName] != moduleName || slices.Contains(info.Types, typeName) { + continue } + + info.Types = append(info.Types, typeName) } - modules = append(modules, mi) + + modules = append(modules, info) } + 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) - } - files = append(files, fi) + files := make([]FileInfo, 0, len(mfv.fileToModules.keys)) + for _, filePath := range mfv.fileToModules.keys { + files = append(files, FileInfo{Path: filePath, Modules: mfv.fileToModules.get(filePath)}) } + 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) IsMultiModuleProject() bool { return len(mfv.moduleToFiles.keys) > 1 } +func (mfv *MultiFileValidator) IsMultiFileProject() bool { return len(mfv.fileToModules.keys) > 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) - } - } - return files + return mfv.moduleToFiles.get(moduleName) } + 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 + return mfv.fileToModules.get(filePath) } diff --git a/pkg/go/validation/multi_file_validation_test.go b/pkg/go/validation/multi_file_validation_test.go new file mode 100644 index 00000000..f88b9ba0 --- /dev/null +++ b/pkg/go/validation/multi_file_validation_test.go @@ -0,0 +1,288 @@ +package validation + +import ( + "slices" + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multiModuleModel names one file, core.fga, from all three places a model can name a +// module: a type, a relation, and a condition. It is the shape the shared corpus uses +// for this rule, with a second relation and a second condition so map iteration has +// something to reorder. +func multiModuleModel() *openfgav1.AuthorizationModel { + return &openfgav1.AuthorizationModel{ + SchemaVersion: "1.2", + TypeDefinitions: []*openfgav1.TypeDefinition{ + { + Type: "user", + Relations: map[string]*openfgav1.Userset{ + "granted": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, + }, + Metadata: &openfgav1.Metadata{ + Module: "core", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + Relations: map[string]*openfgav1.RelationMetadata{ + "granted": { + Module: "usermodule", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + }, + }, + }, + }, + { + Type: "org", + Relations: map[string]*openfgav1.Userset{ + "member": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, + "owner": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, + }, + Metadata: &openfgav1.Metadata{ + Module: "other", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + Relations: map[string]*openfgav1.RelationMetadata{ + "member": { + Module: "relationmodule", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + }, + "owner": { + Module: "ownermodule", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + }, + }, + }, + }, + }, + Conditions: map[string]*openfgav1.Condition{ + "zeta": { + Name: "zeta", + Metadata: &openfgav1.ConditionMetadata{ + Module: "zetamodule", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + }, + }, + "alpha": { + Name: "alpha", + Metadata: &openfgav1.ConditionMetadata{ + Module: "alphamodule", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + }, + }, + }, + } +} + +// TestMultiFileCollectionFollowsTheModelOrder pins the order the modules of one file +// are collected in: every type, then every relation, then every condition. That is the +// order the reference collects them in, and a file's modules are joined into the +// message it reports, so the shared corpus fails on any other order. +func TestMultiFileCollectionFollowsTheModelOrder(t *testing.T) { + t.Parallel() + + model := multiModuleModel() + + // The passes are separate, so the first type's relation is collected after the + // second type, not after its own type. Deliberately not in name order either: + // sorting the modules would be deterministic and would still report the model + // differently from the other SDKs. + want := []string{ + "core", "other", + "usermodule", "relationmodule", "ownermodule", + "alphamodule", "zetamodule", + } + + // Relations and conditions arrive in proto maps, which have no order of their own, + // so one passing run proves nothing: seven modules admit 5040 orders, which 100 + // runs would not agree on by chance. + for i := 0; i < 100; i++ { + files := NewMultiFileValidator(model).GetFileInfo() + + require.Len(t, files, 1, "every definition in this model names core.fga") + require.Equal(t, "core.fga", files[0].Path) + require.Equalf(t, want, files[0].Modules, "run %d collected the modules in a different order", i) + } +} + +// TestValidateMultiFileConsistencyReportsEveryModuleInTheFile is the same rule from the +// entry point the engine calls, including the modules a relation and a condition name, +// which are the two the type-level walk alone would miss. +func TestValidateMultiFileConsistencyReportsEveryModuleInTheFile(t *testing.T) { + t.Parallel() + + collector := NewErrorCollector(nil) + ValidateMultiFileConsistency(collector, multiModuleModel(), nil) + + findings := collector.AllFindings() + require.Len(t, findings, 1) + assert.Equal(t, + "file core.fga would contain multiple module definitions "+ + "(core, other, usermodule, relationmodule, ownermodule, alphamodule, zetamodule) "+ + "when transforming to DSL. Only one module can be defined per file.", + findings[0].Message) + assert.Equal(t, MultipleModulesInFile, findings[0].Metadata.ErrorType) + assert.Equal(t, "core.fga", findings[0].Metadata.Symbol) +} + +// TestRelationInheritsItsTypesFileAndModule covers the fallback field by field: a +// relation that names only a file belongs to its type's module, and a relation that +// names neither belongs to both of its type's. +func TestRelationInheritsItsTypesFileAndModule(t *testing.T) { + t.Parallel() + + model := &openfgav1.AuthorizationModel{ + SchemaVersion: "1.2", + TypeDefinitions: []*openfgav1.TypeDefinition{ + { + Type: "org", + Relations: map[string]*openfgav1.Userset{ + "member": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, + "owner": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, + }, + Metadata: &openfgav1.Metadata{ + Module: "core", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + Relations: map[string]*openfgav1.RelationMetadata{ + "member": {SourceInfo: &openfgav1.SourceInfo{File: "extra.fga"}}, + "owner": {}, + }, + }, + }, + }, + } + + validator := NewMultiFileValidator(model) + + assert.Equal(t, []FileInfo{ + {Path: "core.fga", Modules: []string{"core"}}, + {Path: "extra.fga", Modules: []string{"core"}}, + }, validator.GetFileInfo()) + + assert.True(t, validator.IsMultiFileProject()) + assert.False(t, validator.IsMultiModuleProject(), "both files hold the same module") + assert.Equal(t, []string{"core.fga", "extra.fga"}, validator.GetFilesForModule("core")) + + collector := NewErrorCollector(nil) + ValidateMultiFileConsistency(collector, model, nil) + assert.Empty(t, collector.AllFindings(), "neither file holds more than one module") +} + +func TestMultiFileValidatorReads(t *testing.T) { + t.Parallel() + + validator := NewMultiFileValidator(multiModuleModel()) + + assert.Equal(t, "core", validator.GetModuleForType("user")) + assert.Equal(t, "other", validator.GetModuleForType("org")) + assert.Empty(t, validator.GetModuleForType("absent")) + + assert.Equal(t, "alphamodule", validator.GetModuleForCondition("alpha")) + assert.Equal(t, "zetamodule", validator.GetModuleForCondition("zeta")) + assert.Empty(t, validator.GetModuleForCondition("absent")) + + assert.Equal(t, []string{ + "core", "other", + "usermodule", "relationmodule", "ownermodule", + "alphamodule", "zetamodule", + }, validator.GetModulesForFile("core.fga")) + // Empty rather than nil, so an accessor's result marshals as [] whichever key it + // was asked for. assert.Equal tells the two apart where assert.Empty does not. + assert.Equal(t, []string{}, validator.GetModulesForFile("absent.fga")) + assert.Equal(t, []string{}, validator.GetFilesForModule("absent")) + + // A module reaches GetModuleInfo whether a type, a relation or a condition named + // it, and only a type's module carries types. + assert.Equal(t, []ModuleInfo{ + {Name: "core", Files: []string{"core.fga"}, Types: []string{"user"}}, + {Name: "other", Files: []string{"core.fga"}, Types: []string{"org"}}, + {Name: "usermodule", Files: []string{"core.fga"}, Types: []string{}}, + {Name: "relationmodule", Files: []string{"core.fga"}, Types: []string{}}, + {Name: "ownermodule", Files: []string{"core.fga"}, Types: []string{}}, + {Name: "alphamodule", Files: []string{"core.fga"}, Types: []string{}}, + {Name: "zetamodule", Files: []string{"core.fga"}, Types: []string{}}, + }, validator.GetModuleInfo()) + + assert.True(t, validator.IsMultiModuleProject()) + assert.False(t, validator.IsMultiFileProject(), "one file, however many modules it holds") + + // The reads return copies: reordering what a caller was handed must not reorder + // the record it came from. + modules := validator.GetModulesForFile("core.fga") + modules[0] = "clobbered" + assert.Equal(t, "core", validator.GetModulesForFile("core.fga")[0]) +} + +// TestModuleInfoListsADuplicatedTypeOnce covers reading a model that declares one type +// name twice, which is itself a duplicated-error but is exactly when a caller reaches +// for GetModuleInfo. Walking the declarations reaches such a name once per declaration, +// and typeModuleMap holds one module for it, so without the guard the module that name +// resolves to lists it as many times as the model declares it. +func TestModuleInfoListsADuplicatedTypeOnce(t *testing.T) { + t.Parallel() + + declaredIn := func(module string) *openfgav1.Metadata { + return &openfgav1.Metadata{ + Module: module, + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + } + } + + model := &openfgav1.AuthorizationModel{ + SchemaVersion: "1.2", + TypeDefinitions: []*openfgav1.TypeDefinition{ + {Type: "document", Metadata: declaredIn("first")}, + {Type: "folder", Metadata: declaredIn("first")}, + {Type: "document", Metadata: declaredIn("second")}, + }, + } + + modules := NewMultiFileValidator(model).GetModuleInfo() + + // document resolves to the module that declared it last, and appears there once. + // The module it no longer resolves to does not list it at all. + assert.Equal(t, []ModuleInfo{ + {Name: "first", Files: []string{"core.fga"}, Types: []string{"folder"}}, + {Name: "second", Files: []string{"core.fga"}, Types: []string{"document"}}, + }, modules) + + for _, module := range modules { + assert.Len(t, slices.Compact(slices.Clone(module.Types)), len(module.Types), + "module %q lists a type name more than once", module.Name) + } +} + +// TestMultiFileValidatorWithoutModules covers a model that names no file or module at +// all, which is every model written as a single DSL file. +func TestMultiFileValidatorWithoutModules(t *testing.T) { + t.Parallel() + + 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{}}}}, + }, + }, + } + + validator := NewMultiFileValidator(model) + + assert.Empty(t, validator.GetFileInfo()) + assert.Empty(t, validator.GetModuleInfo()) + assert.False(t, validator.IsMultiFileProject()) + assert.False(t, validator.IsMultiModuleProject()) + + collector := NewErrorCollector(nil) + ValidateMultiFileConsistency(collector, model, nil) + assert.Empty(t, collector.AllFindings()) + + // A nil model reaches the same entry point through the engine, and reports nothing + // rather than panicking. + nilCollector := NewErrorCollector(nil) + ValidateMultiFileConsistency(nilCollector, nil, nil) + assert.Empty(t, nilCollector.AllFindings()) + assert.Empty(t, NewMultiFileValidator(nil).GetFileInfo()) +} diff --git a/pkg/go/validation/name_validation.go b/pkg/go/validation/name_validation.go index 4b9a4150..87201ebe 100644 --- a/pkg/go/validation/name_validation.go +++ b/pkg/go/validation/name_validation.go @@ -2,14 +2,16 @@ package validation import ( "fmt" + "maps" "regexp" + "slices" "strings" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ValidationRegexRules contains the regex rules for validation -// These match the Rules from the JS implementation +// ValidationRegexRules contains the regex rules for validation. +// These match the Rules from the JS implementation. var ValidationRegexRules = struct { Type string Relation string @@ -24,10 +26,10 @@ var ValidationRegexRules = struct { 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 type, relation, and condition name rules are fixed, so compile them +// once. The compiledNameRules map is keyed by the 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. var ( typeNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Type) relationNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Relation) @@ -40,8 +42,8 @@ var ( } ) -// ValidateTypeName validates a type name with both regex and reserved keyword checking -// This enhances the basic regex validation with semantic checks +// 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) { @@ -59,12 +61,12 @@ func ValidateTypeName(typeName string, collector *ErrorCollector, lineIndex *int return true } -// ValidateRelationName validates a relation name with both regex and reserved keyword checking -// This enhances the basic regex validation with semantic checks +// 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) + collector.RaiseReservedRelationName(relationName, typeName, lineIndex, meta) return false } @@ -81,7 +83,7 @@ func ValidateRelationName(relationName, typeName string, collector *ErrorCollect // 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) + collector.RaiseInvalidConditionName(conditionName, conditionNameRule, lineIndex, meta) return false } @@ -101,8 +103,8 @@ func validateFieldValue(rule, value string) bool { return regex.MatchString(value) } -// GetTypeLineNumber finds the line number where a type is defined -// This is equivalent to the getTypeLineNumber function in JS +// 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 @@ -128,7 +130,7 @@ func GetTypeLineNumber(typeName string, lines []string, skipIndex *int) *int { } // GetRelationLineNumber finds the line number where a relation is defined. -// skipIndex, when provided, is the index to begin searching from (inclusive) — +// The skipIndex argument, 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 @@ -162,8 +164,8 @@ func GetRelationLineNumber(relationName string, lines []string, skipIndex *int) return nil } -// GetConditionLineNumber finds the line number where a condition is defined -// This is equivalent to the geConditionLineNumber function in JS +// 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 @@ -193,8 +195,8 @@ func GetConditionLineNumber(conditionName string, lines []string, skipIndex *int 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 +// 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 @@ -228,13 +230,15 @@ func ValidateNames(collector *ErrorCollector, model *openfgav1.AuthorizationMode typeLineIndex := GetTypeLineNumber(typeName, lines, nil) ValidateTypeName(typeName, collector, typeLineIndex, meta) - for relationName := range typeDef.GetRelations() { + for _, relationName := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { relationLineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) ValidateRelationName(relationName, typeName, collector, relationLineIndex, meta) } } - for conditionName, condition := range model.GetConditions() { + conditions := model.GetConditions() + for _, conditionName := range slices.Sorted(maps.Keys(conditions)) { + condition := conditions[conditionName] conditionLineIndex := GetConditionLineNumber(conditionName, lines, nil) meta := &Meta{ File: condition.GetMetadata().GetSourceInfo().GetFile(), diff --git a/pkg/go/validation/name_validation_test.go b/pkg/go/validation/name_validation_test.go index 61520757..4229ded9 100644 --- a/pkg/go/validation/name_validation_test.go +++ b/pkg/go/validation/name_validation_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/assert" ) - func TestValidationRegexRules(t *testing.T) { // Test that regex rules match the JS implementation assert.Equal(t, "[^:#@\\*\\s]{1,254}", ValidationRegexRules.Type) @@ -130,7 +129,7 @@ func TestValidateTypeName(t *testing.T) { assert.Equal(t, tt.expectedValid, result) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 { @@ -216,7 +215,7 @@ func TestValidateRelationName(t *testing.T) { assert.Equal(t, tt.expectedValid, result) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 { @@ -281,12 +280,15 @@ func TestValidateConditionName(t *testing.T) { assert.Equal(t, tt.expectedValid, result) - errors := collector.GetErrors() + errors := collector.AllFindings() 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) + // The finding is scoped to the condition, not to a type. + assert.Equal(t, tt.conditionName, errors[0].Metadata.Condition) + assert.Empty(t, errors[0].Metadata.Type) } }) } @@ -593,7 +595,7 @@ func TestValidateNameRules(t *testing.T) { ValidateNameRules(collector, tt.typeName, tt.relationNames, typeLineIndex, meta, tt.lines) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) }) } diff --git a/pkg/go/validation/schema_validation.go b/pkg/go/validation/schema_validation.go index 9e2995fa..3258cde7 100644 --- a/pkg/go/validation/schema_validation.go +++ b/pkg/go/validation/schema_validation.go @@ -29,7 +29,12 @@ func GetSchemaLineNumber(schemaVersion string, lines []string) *int { if len(lines) == 0 { return nil } - pattern := `^\s*schema\s+` + regexp.QuoteMeta(schemaVersion) + `\s*$` + // 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. This mirrors the reference's + // getSchemaLineNumber; without it a commented schema line resolves to no + // position and the finding reaches the caller with no line or column. + pattern := `^\s*schema\s+` + regexp.QuoteMeta(schemaVersion) + `(\s+#.*)?\s*$` regex := regexp.MustCompile(pattern) for i, line := range lines { normalizedLine := strings.TrimSpace(line) @@ -64,23 +69,25 @@ func ValidateSchemaVersion(collector *ErrorCollector, model *openfgav1.Authoriza } } -// 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 { +// ValidateMultipleModulesInFile reports every file that declares more than one +// module. +// +// It reports the files, and each file's modules, in the order they were collected +// from the model, which is the order the reference reports them in and the order the +// shared corpus expects. +func ValidateMultipleModulesInFile(collector *ErrorCollector, files []FileInfo) { + for _, file := range files { + if len(file.Modules) <= 1 { continue } - modules := make([]string, 0, len(moduleMap)) - for module := range moduleMap { - modules = append(modules, module) - } - collector.RaiseMultipleModulesInSingleFile(file, modules) + + collector.RaiseMultipleModulesInSingleFile(file.Path, file.Modules) } } // ValidateBasicModelStructure performs basic model structure validation. func ValidateBasicModelStructure(collector *ErrorCollector, model *openfgav1.AuthorizationModel, - fileToModuleMap map[string]map[string]bool, lines []string) { + files []FileInfo, lines []string) { ValidateSchemaVersion(collector, model, lines) - ValidateMultipleModulesInFile(collector, fileToModuleMap) + ValidateMultipleModulesInFile(collector, files) } diff --git a/pkg/go/validation/schema_validation_test.go b/pkg/go/validation/schema_validation_test.go index 53c494a5..46f93994 100644 --- a/pkg/go/validation/schema_validation_test.go +++ b/pkg/go/validation/schema_validation_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) - func TestIsValidSchemaVersion(t *testing.T) { tests := []struct { name string @@ -208,7 +207,7 @@ func TestValidateSchemaVersion(t *testing.T) { ValidateSchemaVersion(collector, tt.model, tt.lines) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 { @@ -222,50 +221,58 @@ func TestValidateSchemaVersion(t *testing.T) { func TestValidateMultipleModulesInFile(t *testing.T) { tests := []struct { name string - fileToModuleMap map[string]map[string]bool + files []FileInfo expectedErrorCount int expectedFile string - expectedModules []string + expectedMessage string }{ { name: "no files", - fileToModuleMap: map[string]map[string]bool{}, + files: nil, expectedErrorCount: 0, }, { name: "single module per file", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, - "file2.fga": {"module2": true}, + files: []FileInfo{ + {Path: "file1.fga", Modules: []string{"module1"}}, + {Path: "file2.fga", Modules: []string{"module2"}}, }, expectedErrorCount: 0, }, { name: "multiple modules in single file", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - "module3": true, - }, + files: []FileInfo{ + {Path: "file1.fga", Modules: []string{"module1", "module2", "module3"}}, }, expectedErrorCount: 1, expectedFile: "file1.fga", - expectedModules: []string{"module1", "module2", "module3"}, + expectedMessage: "file file1.fga would contain multiple module definitions " + + "(module1, module2, module3) when transforming to DSL. Only one module can be defined per file.", }, { 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}, + files: []FileInfo{ + {Path: "file1.fga", Modules: []string{"module1"}}, + {Path: "file2.fga", Modules: []string{"module2", "module3"}}, + {Path: "file3.fga", Modules: []string{"module4"}}, }, expectedErrorCount: 1, expectedFile: "file2.fga", - expectedModules: []string{"module2", "module3"}, + expectedMessage: "file file2.fga would contain multiple module definitions " + + "(module2, module3) when transforming to DSL. Only one module can be defined per file.", + }, + { + // The modules reach the message in the order they were collected, which is + // the order the reference reports them in. Sorting them here would read as + // harmless and would diverge from the other SDKs. + name: "modules are reported in the order given, not in name order", + files: []FileInfo{ + {Path: "core.fga", Modules: []string{"zulu", "alpha", "mike"}}, + }, + expectedErrorCount: 1, + expectedFile: "core.fga", + expectedMessage: "file core.fga would contain multiple module definitions " + + "(zulu, alpha, mike) when transforming to DSL. Only one module can be defined per file.", }, } @@ -273,19 +280,15 @@ func TestValidateMultipleModulesInFile(t *testing.T) { t.Run(tt.name, func(t *testing.T) { collector := NewErrorCollector(nil) - ValidateMultipleModulesInFile(collector, tt.fileToModuleMap) + ValidateMultipleModulesInFile(collector, tt.files) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) if tt.expectedErrorCount > 0 { assert.Equal(t, MultipleModulesInFile, errors[0].Metadata.ErrorType) assert.Equal(t, tt.expectedFile, errors[0].Metadata.Symbol) - - // Check that all expected modules are mentioned in the error message - for _, module := range tt.expectedModules { - assert.Contains(t, errors[0].Message, module) - } + assert.Equal(t, tt.expectedMessage, errors[0].Message) } }) } @@ -295,7 +298,7 @@ func TestValidateBasicModelStructure(t *testing.T) { tests := []struct { name string model *openfgav1.AuthorizationModel - fileToModuleMap map[string]map[string]bool + files []FileInfo lines []string expectedErrorCount int }{ @@ -304,15 +307,15 @@ func TestValidateBasicModelStructure(t *testing.T) { model: &openfgav1.AuthorizationModel{ SchemaVersion: "1.1", }, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, + files: []FileInfo{ + {Path: "file1.fga", Modules: []string{"module1"}}, }, expectedErrorCount: 0, }, { name: "missing schema version", model: &openfgav1.AuthorizationModel{}, - fileToModuleMap: map[string]map[string]bool{}, + files: nil, expectedErrorCount: 1, }, { @@ -320,7 +323,7 @@ func TestValidateBasicModelStructure(t *testing.T) { model: &openfgav1.AuthorizationModel{ SchemaVersion: "2.0", }, - fileToModuleMap: map[string]map[string]bool{}, + files: nil, expectedErrorCount: 1, }, { @@ -328,22 +331,16 @@ func TestValidateBasicModelStructure(t *testing.T) { model: &openfgav1.AuthorizationModel{ SchemaVersion: "1.1", }, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - }, + files: []FileInfo{ + {Path: "file1.fga", Modules: []string{"module1", "module2"}}, }, expectedErrorCount: 1, }, { name: "multiple errors", model: &openfgav1.AuthorizationModel{}, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - }, + files: []FileInfo{ + {Path: "file1.fga", Modules: []string{"module1", "module2"}}, }, expectedErrorCount: 2, }, @@ -353,9 +350,9 @@ func TestValidateBasicModelStructure(t *testing.T) { t.Run(tt.name, func(t *testing.T) { collector := NewErrorCollector(tt.lines) - ValidateBasicModelStructure(collector, tt.model, tt.fileToModuleMap, tt.lines) + ValidateBasicModelStructure(collector, tt.model, tt.files, tt.lines) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, tt.expectedErrorCount) }) } @@ -384,7 +381,7 @@ func TestSchemaVersionValidation(t *testing.T) { SchemaVersion: "1.1", } ValidateSchemaVersion(collector, validModel, nil) - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, collector.AllFindings()) // Test invalid schema version collector = NewErrorCollector(nil) @@ -392,7 +389,7 @@ func TestSchemaVersionValidation(t *testing.T) { SchemaVersion: "2.0", } ValidateSchemaVersion(collector, invalidModel, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, InvalidSchema, errors[0].Metadata.ErrorType) } diff --git a/pkg/go/validation/semantic_validation.go b/pkg/go/validation/semantic_validation.go index 273a2d44..96419b96 100644 --- a/pkg/go/validation/semantic_validation.go +++ b/pkg/go/validation/semantic_validation.go @@ -1,6 +1,9 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -60,16 +63,6 @@ func (sv *SemanticValidator) GetRelationUserset(typeName, relationName string) * 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 @@ -121,13 +114,21 @@ func validateRelationReferences(collector *ErrorCollector, validator *SemanticVa // `define` occurrence is found when several types share a relation name. typeLineIndex := GetTypeLineNumber(typeName, lines, nil) + // Relations are walked in name order here and in every other phase: they + // reach us in a proto map, which has no order, so ranging it directly would + // report the same model's findings in a different order each run. 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)) { + validateTypeRestrictions(collector, validator, typeName, relationName, + relationsMetadata[relationName], typeLineIndex, lines) } } - 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)) { + validateUsersetReferences(collector, validator, typeName, relationName, + relations[relationName], typeLineIndex, lines) } } } @@ -158,7 +159,8 @@ func validateTypeRestrictions(collector *ErrorCollector, validator *SemanticVali if !validator.RelationDefined(restrictedType, rel) { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) symbol := restrictedType + "#" + rel - collector.RaiseInvalidTypeRelation(symbol, restrictedType, relationName, rel, restrictedType, lineIndex, meta) + // offendingType is the enclosing type the restriction was written in. + collector.RaiseInvalidTypeRelation(symbol, restrictedType, relationName, rel, typeName, lineIndex, meta) } } } @@ -181,8 +183,7 @@ func validateUsersetReferences(collector *ErrorCollector, validator *SemanticVal 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) + collector.RaiseInvalidRelationError(targetRelation, typeName, relationName, lineIndex, meta) } } } diff --git a/pkg/go/validation/semantic_validation_test.go b/pkg/go/validation/semantic_validation_test.go index 977d7582..e04310d8 100644 --- a/pkg/go/validation/semantic_validation_test.go +++ b/pkg/go/validation/semantic_validation_test.go @@ -5,6 +5,9 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" ) func TestSemanticValidator(t *testing.T) { @@ -96,11 +99,11 @@ func TestSemanticValidator(t *testing.T) { docType := validator.GetTypeDefinition("document") assert.NotNil(t, docType) - assert.Equal(t, "document", docType.Type) + assert.Equal(t, "document", docType.GetType()) userType := validator.GetTypeDefinition("user") assert.NotNil(t, userType) - assert.Equal(t, "user", userType.Type) + assert.Equal(t, "user", userType.GetType()) groupType := validator.GetTypeDefinition("group") assert.Nil(t, groupType) @@ -139,7 +142,7 @@ func TestValidateRelationReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateRelationReferences(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Empty(t, errors) }) @@ -164,7 +167,7 @@ func TestValidateRelationReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateRelationReferences(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() assert.Len(t, errors, 1) assert.Equal(t, InvalidType, errors[0].Metadata.ErrorType) assert.Contains(t, errors[0].Message, "undefined_type") @@ -194,10 +197,23 @@ func TestValidateRelationReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateRelationReferences(collector, model, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) + errors := collector.AllFindings() + require.Len(t, errors, 1) assert.Equal(t, InvalidRelationType, errors[0].Metadata.ErrorType) assert.Contains(t, errors[0].Message, "undefined_relation") + + // The restriction names user#undefined_relation, so the finding is scoped to + // the restricted type, while offendingType is the type the restriction was + // written in. This is the split pkg/js reports: typeName is the restricted + // type, offendingType the enclosing one. + assert.Equal(t, "user", errors[0].Metadata.Type) + assert.Equal(t, "viewer", errors[0].Metadata.Relation) + assert.Equal(t, "document", errors[0].Metadata.OffendingType) + + var scoped *fgaerrors.ErrRelation + require.ErrorAs(t, errors[0], &scoped) + assert.Equal(t, "user", scoped.ObjectType) + assert.Equal(t, "viewer", scoped.Relation) }) t.Run("Undefined relation in computed userset", func(t *testing.T) { @@ -221,10 +237,16 @@ func TestValidateRelationReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateRelationReferences(collector, model, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) + errors := collector.AllFindings() + require.Len(t, errors, 1) assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) + assert.Equal(t, "viewer", errors[0].Metadata.Relation) assert.Contains(t, errors[0].Message, "undefined_relation") + + var scoped *fgaerrors.ErrRelation + require.ErrorAs(t, errors[0], &scoped) + assert.Equal(t, "document", scoped.ObjectType) + assert.Equal(t, "viewer", scoped.Relation) }) t.Run("Complex userset validation", func(t *testing.T) { @@ -268,7 +290,7 @@ func TestValidateRelationReferences(t *testing.T) { collector := NewErrorCollector(nil) ValidateRelationReferences(collector, model, nil) - errors := collector.GetErrors() + errors := collector.AllFindings() 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/severity_fixtures_test.go b/pkg/go/validation/severity_fixtures_test.go new file mode 100644 index 00000000..05283ff3 --- /dev/null +++ b/pkg/go/validation/severity_fixtures_test.go @@ -0,0 +1,205 @@ +package validation + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// severityFixtureFile holds the Go-only expectations for severity, category and +// criticality. See the header of that file for why it is not in tests/data. +const severityFixtureFile = "testdata/severity-category-cases.yaml" + +type severityFixtureScope struct { + ObjectType string `yaml:"object_type"` + Relation string `yaml:"relation"` + Condition string `yaml:"condition"` +} + +type severityFixtureExpectation struct { + ErrorType string `yaml:"error_type"` + Severity string `yaml:"severity"` + Category string `yaml:"category"` + Critical bool `yaml:"critical"` + Sentinel string `yaml:"sentinel"` + Scope severityFixtureScope `yaml:"scope"` +} + +type severityFixtureCase struct { + Name string `yaml:"name"` + DSL string `yaml:"dsl"` + Expected []severityFixtureExpectation `yaml:"expected"` +} + +// sentinelsByName resolves the sentinel a fixture names. YAML cannot reference a Go +// value, so fixtures name the sentinel as a string; an unknown name fails rather +// than being skipped. +var sentinelsByName = map[string]error{ + "ErrInvalidType": fgaerrors.ErrInvalidType, + "ErrDuplicateDefinition": fgaerrors.ErrDuplicateDefinition, + "ErrNoEntrypoints": fgaerrors.ErrNoEntrypoints, + "ErrConditionUnReferenced": fgaerrors.ErrConditionUnReferenced, + "ErrReservedKeywords": fgaerrors.ErrReservedKeywords, + "ErrObjectTypeUndefined": fgaerrors.ErrObjectTypeUndefined, + "ErrRelationUndefined": fgaerrors.ErrRelationUndefined, + "ErrInvalidRelationType": fgaerrors.ErrInvalidRelationType, + "ErrInvalidSchemaVersion": fgaerrors.ErrInvalidSchemaVersion, + "ErrMultipleModulesInFile": fgaerrors.ErrMultipleModulesInFile, + "ErrInvalidWildcard": fgaerrors.ErrInvalidWildcard, + "ErrConditionUndefined": fgaerrors.ErrConditionUndefined, + "ErrConditionNameMismatch": fgaerrors.ErrConditionNameMismatch, + "ErrInvalidName": fgaerrors.ErrInvalidName, + "ErrDirectlyAssignableRelation": fgaerrors.ErrDirectlyAssignableRelation, +} + +func loadSeverityFixtures(t *testing.T) []severityFixtureCase { + t.Helper() + + contents, err := os.ReadFile(severityFixtureFile) + require.NoError(t, err, "reading %s", severityFixtureFile) + + var cases []severityFixtureCase + require.NoError(t, yaml.Unmarshal(contents, &cases)) + require.NotEmpty(t, cases, "fixture file parsed to no cases") + + return cases +} + +// TestSeverityFixtures runs the Go-only fixtures. Each expectation names a finding +// the validator must produce, with its severity, category, criticality, the sentinel +// errors.Is matches and the scope errors.As exposes. +func TestSeverityFixtures(t *testing.T) { + t.Parallel() + + for _, fixture := range loadSeverityFixtures(t) { + t.Run(fixture.Name, func(t *testing.T) { + t.Parallel() + + validationErrors := validateDSL(t, fixture.DSL) + require.NotNil(t, validationErrors) + + findings := validationErrors.AllFindings() + + for _, want := range fixture.Expected { + sentinel, ok := sentinelsByName[want.Sentinel] + require.Truef(t, ok, + "fixture names sentinel %q, which is not in sentinelsByName", want.Sentinel) + + matched := findSeverityFixtureMatch(findings, want) + require.NotNilf(t, matched, + "no finding matched error_type=%q scope=%+v; got %s", + want.ErrorType, want.Scope, validationErrors.Error()) + + assert.Equal(t, want.Severity, matched.Severity.String()) + assert.Equal(t, want.Category, matched.Category.String()) + assert.Equal(t, want.Critical, isCriticalErrorType(matched.Metadata.ErrorType)) + assert.Equal(t, want.Severity == "error", matched.Blocks()) + + require.ErrorIsf(t, error(matched), sentinel, + "finding %q does not match %s via errors.Is", want.ErrorType, want.Sentinel) + + // The finding was selected on the scope errors.As reports, so + // asserting the metadata here checks the two agree: the metadata + // is derived from the cause and must not drift from it. + require.NotNil(t, matched.Metadata) + assert.Equal(t, want.Scope.ObjectType, matched.Metadata.Type) + assert.Equal(t, want.Scope.Relation, matched.Metadata.Relation) + assert.Equal(t, want.Scope.Condition, matched.Metadata.Condition) + } + }) + } +} + +// findSeverityFixtureMatch locates the finding an expectation refers to. Matching +// on error type alone is not enough: the no-entrypoint case produces one finding +// per relation, so the scope is part of the identity. +func findSeverityFixtureMatch( + findings []*ValidationError, want severityFixtureExpectation, +) *ValidationError { + for _, finding := range findings { + if finding.Metadata == nil || string(finding.Metadata.ErrorType) != want.ErrorType { + continue + } + + if finding.Unwrap() == nil { + continue + } + + objectType, relation, condition := causeScope(error(finding)) + if objectType == want.Scope.ObjectType && + relation == want.Scope.Relation && + condition == want.Scope.Condition { + return finding + } + } + + return nil +} + +// TestSeverityFixturesAreNotInTheSharedCorpus keeps these keys out of tests/data. An +// unknown key there breaks the Java suite on deserialisation and the JS suite on its +// toMatchObject assertions; see the fixture file's header. +func TestSeverityFixturesAreNotInTheSharedCorpus(t *testing.T) { + t.Parallel() + + sharedCorpus := filepath.Join("..", "..", "..", "tests", "data", "dsl-semantic-validation-cases.yaml") + + contents, err := os.ReadFile(sharedCorpus) + require.NoError(t, err, "reading the shared corpus") + + var cases []map[string]any + require.NoError(t, yaml.Unmarshal(contents, &cases)) + + // Keys pkg/js and pkg/java have no field for. + goOnlyKeys := []string{"severity", "category", "critical", "sentinel", "scope"} + + for index, testCase := range cases { + for _, key := range goOnlyKeys { + _, present := testCase[key] + assert.Falsef(t, present, + "shared corpus case %d has Go-only key %q; it belongs in %s until "+ + "pkg/js and pkg/java can read it", index, key, severityFixtureFile) + } + + expectedErrors, ok := testCase["expected_errors"].([]any) + if !ok { + continue + } + + for _, raw := range expectedErrors { + expectedError, ok := raw.(map[string]any) + if !ok { + continue + } + + for _, key := range goOnlyKeys { + _, present := expectedError[key] + assert.Falsef(t, present, + "shared corpus case %d has Go-only key %q inside expected_errors", index, key) + } + } + } +} + +// TestEveryFixtureSentinelIsReal stops sentinelsByName from drifting into a map +// of names that no longer exist, which would make the fixtures silently skip. +func TestEveryFixtureSentinelIsReal(t *testing.T) { + t.Parallel() + + for name, sentinel := range sentinelsByName { + require.Errorf(t, sentinel, "%s resolves to a nil error", name) + } + + for _, fixture := range loadSeverityFixtures(t) { + for _, want := range fixture.Expected { + _, ok := sentinelsByName[want.Sentinel] + assert.Truef(t, ok, "fixture %q names unknown sentinel %q", fixture.Name, want.Sentinel) + } + } +} diff --git a/pkg/go/validation/severity_predicates_test.go b/pkg/go/validation/severity_predicates_test.go new file mode 100644 index 00000000..0369c465 --- /dev/null +++ b/pkg/go/validation/severity_predicates_test.go @@ -0,0 +1,344 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// No validation emits a non-blocking severity yet: every errorInfoByType entry is +// SeverityError. The tests below build findings directly, which is the only way to +// pin the severity predicates while nothing emits one. + +func finding(severity fgaerrors.Severity, message string) *ValidationError { + return &ValidationError{ + Message: message, + Severity: severity, + Metadata: &ErrorMetadata{ErrorType: RelationNoEntrypoint}, + } +} + +func TestPredicatesCountBlockingOnly(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + findings []*ValidationError + wantHasErrors bool + wantCount int + wantAllCount int + }{ + "nothing at all": { + findings: nil, + wantHasErrors: false, + wantCount: 0, + wantAllCount: 0, + }, + "advisory only": { + findings: []*ValidationError{finding(fgaerrors.SeverityAdvisory, "check may answer differently")}, + wantHasErrors: false, + wantCount: 0, + wantAllCount: 1, + }, + "warning only": { + findings: []*ValidationError{finding(fgaerrors.SeverityWarning, "uses a construct a future version may reject")}, + wantHasErrors: false, + wantCount: 0, + wantAllCount: 1, + }, + "one error among non-blocking": { + findings: []*ValidationError{ + finding(fgaerrors.SeverityAdvisory, "advisory"), + finding(fgaerrors.SeverityError, "real error"), + finding(fgaerrors.SeverityWarning, "warning"), + }, + wantHasErrors: true, + wantCount: 1, + wantAllCount: 3, + }, + "severity unset counts as blocking": { + findings: []*ValidationError{{Message: "built by hand"}}, + wantHasErrors: true, + wantCount: 1, + wantAllCount: 1, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + validationErrors := NewValidationErrors(test.findings) + + assert.Equal(t, test.wantHasErrors, validationErrors.HasErrors()) + assert.Equal(t, test.wantCount, validationErrors.Count()) + assert.Equal(t, test.wantAllCount, validationErrors.CountAll()) + assert.Equal(t, test.wantAllCount > 0, validationErrors.HasFindings()) + assert.Len(t, validationErrors.GetErrors(), test.wantCount) + assert.Len(t, validationErrors.AllFindings(), test.wantAllCount) + }) + } +} + +// TestValidModelWithAdvisoryIsStillValid checks an advisory does not fail a model. +// An advisory describes a model that is correct, so reporting one must not +// invalidate it. +func TestValidModelWithAdvisoryIsStillValid(t *testing.T) { + t.Parallel() + + report := ValidationReport{ + ValidationErrors: NewValidationErrors([]*ValidationError{ + finding(fgaerrors.SeverityAdvisory, "a check against this model may answer differently"), + finding(fgaerrors.SeverityWarning, "this model uses a construct a future version may reject"), + }), + } + + assert.True(t, report.IsValid(), "warnings and advisories must not make a model invalid") + assert.True(t, report.ValidationErrors.HasFindings(), "but they must still be reported") + assert.Equal(t, "no validation errors", report.ValidationErrors.Error(), + "the error string describes blocking findings, and there are none") +} + +// TestErrorOrNilIsNilWhenNothingBlocks is the entry-point half of the same rule: +// a model whose only findings are warnings or advisories is valid, so err != nil +// means the model is invalid and not that something was reported. +func TestErrorOrNilIsNilWhenNothingBlocks(t *testing.T) { + t.Parallel() + + nonBlocking := NewValidationErrors([]*ValidationError{ + finding(fgaerrors.SeverityWarning, "this model uses a construct a future version may reject"), + finding(fgaerrors.SeverityAdvisory, "a check against this model may answer differently"), + }) + + // A literal nil, not a nil *ValidationErrors: the latter is a non-nil error + // however few findings it holds. + require.NoError(t, nonBlocking.ErrorOrNil()) + assert.True(t, nonBlocking.HasFindings(), "the findings are still there to be reported") +} + +func TestErrorOrNilCarriesEverythingWhenSomethingBlocks(t *testing.T) { + t.Parallel() + + blocking := NewValidationErrors([]*ValidationError{ + finding(fgaerrors.SeverityWarning, "warning"), + finding(fgaerrors.SeverityError, "boom"), + }) + + err := blocking.ErrorOrNil() + require.Error(t, err) + + var recovered *ValidationErrors + require.ErrorAs(t, err, &recovered) + assert.Len(t, recovered.AllFindings(), 2, "the non-blocking finding travels with the blocking one") +} + +// TestErrorOrNilOnANilCollection covers the nil receiver, since the entry points +// call it on whatever RunAllValidations handed back. +func TestErrorOrNilOnANilCollection(t *testing.T) { + t.Parallel() + + var nilCollection *ValidationErrors + + assert.NoError(t, nilCollection.ErrorOrNil()) +} + +// TestUnwrapReachesEveryFinding checks errors.Is sees a non-blocking finding too. +// Unwrap answers "was this condition reported", which is a different question from +// "does the model still validate". +func TestUnwrapReachesEveryFinding(t *testing.T) { + t.Parallel() + + warning := finding(fgaerrors.SeverityWarning, "warned") + warning.Cause = &fgaerrors.ErrRelation{ObjectType: "document", Relation: "viewer", Cause: fgaerrors.ErrNoEntrypoints} + + collection := NewValidationErrors([]*ValidationError{ + finding(fgaerrors.SeverityError, "boom"), + warning, + }) + + require.ErrorIs(t, collection, fgaerrors.ErrNoEntrypoints, + "the sentinel of a warning must still be reachable") + + var scoped *fgaerrors.ErrRelation + require.ErrorAs(t, collection, &scoped) + assert.Equal(t, "viewer", scoped.Relation) +} + +// TestUnwrapSkipsNilFindings checks a directly-constructed collection holding a nil +// entry does not panic: a nil *ValidationError handed to errors.Is as a non-nil +// error would dereference nil on Unwrap. +func TestUnwrapSkipsNilFindings(t *testing.T) { + t.Parallel() + + collection := NewValidationErrors([]*ValidationError{nil, finding(fgaerrors.SeverityError, "boom")}) + + assert.Len(t, collection.Unwrap(), 1) + assert.NotErrorIs(t, collection, fgaerrors.ErrNoEntrypoints) +} + +func TestBlockingFindingMakesModelInvalid(t *testing.T) { + t.Parallel() + + report := ValidationReport{ + ValidationErrors: NewValidationErrors([]*ValidationError{ + finding(fgaerrors.SeverityAdvisory, "advisory"), + finding(fgaerrors.SeverityError, "boom"), + }), + } + + assert.False(t, report.IsValid()) + assert.Contains(t, report.ValidationErrors.Error(), "1 error occurred", + "the count must agree with Count(), not with len(Errors)") + assert.NotContains(t, report.ValidationErrors.Error(), "advisory") +} + +// TestCascadeGateIgnoresNonBlockingFindings checks the gate in RunAllValidations +// counts only blocking findings. If it counted advisories, one advisory raised early +// would skip every gated phase and hide the errors they would have found. +func TestCascadeGateIgnoresNonBlockingFindings(t *testing.T) { + t.Parallel() + + collector := NewErrorCollector(nil) + collector.errors = append(collector.errors, + finding(fgaerrors.SeverityAdvisory, "advisory"), + finding(fgaerrors.SeverityWarning, "warning"), + ) + + require.False(t, collector.HasErrors(), + "a collector holding only non-blocking findings must not close the cascade gate") + assert.Equal(t, 0, collector.Count()) + assert.Equal(t, 2, collector.CountAll()) + assert.Len(t, collector.AllFindings(), 2, + "the collector is the raw record and filters nothing") + + collector.errors = append(collector.errors, finding(fgaerrors.SeverityError, "real error")) + assert.True(t, collector.HasErrors(), "a blocking finding must close the gate") +} + +// TestSummarySplitsBySeverity checks the summary reports both totals, so a +// consumer can say "3 findings, 1 of which fails the model". +func TestSummarySplitsBySeverity(t *testing.T) { + t.Parallel() + + engine := &ValidationEngine{collector: NewErrorCollector(nil)} + engine.collector.errors = append(engine.collector.errors, + finding(fgaerrors.SeverityError, "error"), + finding(fgaerrors.SeverityWarning, "warning"), + finding(fgaerrors.SeverityAdvisory, "advisory"), + ) + + summary := engine.GetValidationSummary() + + assert.Equal(t, 1, summary.TotalErrors, "only blocking findings are errors") + assert.Equal(t, 3, summary.TotalFindings) + assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityError]) + assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityWarning]) + assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityAdvisory]) + assert.Equal(t, 3, summary.ErrorsByType[RelationNoEntrypoint], + "the by-type breakdown covers every finding, so it sums to TotalFindings") +} + +// TestGetErrorsByTypeIgnoresSeverity checks the deliberate exception: the caller +// asked for a specific code, so filtering by severity as well would drop matches it +// explicitly requested. +func TestGetErrorsByTypeIgnoresSeverity(t *testing.T) { + t.Parallel() + + report := ValidationReport{ + ValidationErrors: NewValidationErrors([]*ValidationError{ + finding(fgaerrors.SeverityAdvisory, "advisory"), + finding(fgaerrors.SeverityError, "error"), + }), + } + + assert.Len(t, report.GetErrorsByType(RelationNoEntrypoint), 2) + assert.Empty(t, report.GetErrorsByType(UndefinedType)) +} + +// TestRealValidationStillFails checks the severity predicates do not stop real +// errors from counting. Every errorInfoByType entry is blocking, so real validation +// must still fail. +func TestRealValidationStillFails(t *testing.T) { + t.Parallel() + + validationErrors := validateDSL(t, `model + schema 1.1 +type document + relations + define viewer: [user] +`) + + require.True(t, validationErrors.HasErrors(), "an undefined type must still fail validation") + assert.Positive(t, validationErrors.Count()) + assert.Equal(t, validationErrors.CountAll(), validationErrors.Count(), + "nothing emits a non-blocking severity yet, so the two counts must agree") +} + +// TestPredicatesSurviveAWholeCollectionOfNothing pins the reads that a caller can +// reach without going through the collector: a nil collection, and one holding a nil +// finding. Every read goes through ValidationErrors.findings, so this covers the set. +func TestPredicatesSurviveAWholeCollectionOfNothing(t *testing.T) { + t.Parallel() + + var absent *ValidationErrors + + assert.False(t, absent.HasErrors()) + assert.False(t, absent.HasFindings()) + assert.Equal(t, 0, absent.Count()) + assert.Equal(t, 0, absent.CountAll()) + assert.Empty(t, absent.GetErrors()) + assert.Empty(t, absent.AllFindings()) + assert.Empty(t, absent.Unwrap()) + require.NoError(t, absent.ErrorOrNil()) + assert.Equal(t, "no validation errors", absent.Error()) + + // A nil finding is not a finding: every read drops it, so the counts agree with + // each other and nothing hands a caller an entry that dereferences nil. + held := NewValidationErrors([]*ValidationError{nil, finding(fgaerrors.SeverityError, "real")}) + + assert.True(t, held.HasErrors()) + assert.True(t, held.HasFindings()) + assert.Equal(t, 1, held.Count()) + assert.Equal(t, 1, held.CountAll(), "the nil entry is not a finding to count") + assert.Len(t, held.GetErrors(), 1) + assert.Len(t, held.AllFindings(), 1) + assert.Len(t, held.Unwrap(), 1) + assert.Contains(t, held.Error(), "real") + + // Every entry AllFindings returns is safe to dereference, which is why the nil is + // dropped rather than counted. + for _, f := range held.AllFindings() { + assert.Equal(t, fgaerrors.SeverityError, f.Severity) + assert.Contains(t, f.String(), "real") + } + + // A collection of nothing but nil reports nothing, rather than reporting a count + // while Unwrap and Error report none. + onlyNil := NewValidationErrors([]*ValidationError{nil}) + + assert.False(t, onlyNil.HasFindings(), "a nil entry is not something reported") + assert.Equal(t, 0, onlyNil.CountAll()) + assert.Empty(t, onlyNil.AllFindings()) + require.NoError(t, onlyNil.ErrorOrNil()) + + // Add is the other way in. + added := NewValidationErrors(nil) + added.Add(nil) + assert.False(t, added.HasFindings()) + assert.Equal(t, 0, added.CountAll()) + + // A zero report reaches a nil collection through IsValid. + var report ValidationReport + + assert.True(t, report.IsValid()) + assert.False(t, report.HasCriticalErrors()) + assert.Empty(t, report.GetErrorsByType(UndefinedType)) + + // GetErrorsByType reads the code off metadata, which a hand-built finding can omit. + withoutMetadata := ValidationReport{ + ValidationErrors: NewValidationErrors([]*ValidationError{nil, {Message: "no metadata"}}), + } + assert.Empty(t, withoutMetadata.GetErrorsByType(UndefinedType)) +} diff --git a/pkg/go/validation/testdata/severity-category-cases.yaml b/pkg/go/validation/testdata/severity-category-cases.yaml new file mode 100644 index 00000000..de02a4a2 --- /dev/null +++ b/pkg/go/validation/testdata/severity-category-cases.yaml @@ -0,0 +1,137 @@ +--- +# Go-only fixtures for the severity, category and criticality that pkg/go attaches +# to each validation finding. +# +# They live here rather than in tests/data/dsl-semantic-validation-cases.yaml because +# that corpus is shared with pkg/js and pkg/java, and neither carries a severity or +# category concept yet. Adding the keys there breaks both suites: pkg/java reads the +# corpus through a bare YAMLMapper onto case classes with no @JsonIgnoreProperties, so +# Jackson rejects an unrecognised key outright, and pkg/js asserts each expected error +# with toMatchObject, which fails on an expected key the error object does not carry. +# If either picks these fields up, these cases are what the shared corpus should +# absorb. +# +# Fields: +# error_type — the slug, as it appears in metadata.errorType +# severity — error | warning | advisory; error means the model is invalid +# category — the part of the model the finding is about +# critical — the model as a whole is unusable, not just one relation +# sentinel — name of the errors.Is target in pkg/go/errors +# scope — which scope fields the wrapped cause must carry + +- name: undefined type in a type restriction + dsl: | + model + schema 1.1 + type document + relations + define viewer: [user] + expected: + - error_type: invalid-type + severity: error + category: object-type + critical: false + sentinel: ErrInvalidType + scope: + object_type: user + +- name: duplicate type definition + dsl: | + model + schema 1.1 + type user + type document + type document + expected: + - error_type: duplicated-error + severity: error + category: object-type + critical: true + sentinel: ErrDuplicateDefinition + scope: + object_type: document + +- name: relation with no entrypoint + dsl: | + model + schema 1.1 + type user + type document + relations + define viewer: writer + define writer: viewer + expected: + - error_type: relation-no-entry-point + severity: error + category: relation + critical: true + sentinel: ErrNoEntrypoints + scope: + object_type: document + relation: viewer + - error_type: relation-no-entry-point + severity: error + category: relation + critical: true + sentinel: ErrNoEntrypoints + scope: + object_type: document + relation: writer + +- name: condition defined but never referenced + dsl: | + model + schema 1.1 + type user + type document + relations + define viewer: [user] + + condition inRegion(x: string) { + x == "eu" + } + expected: + - error_type: condition-not-used + severity: error + category: condition + critical: false + sentinel: ErrConditionUnReferenced + scope: + condition: inRegion + +- name: reserved keyword as a type name + dsl: | + model + schema 1.1 + type user + type self + expected: + - error_type: reserved-type-keywords + severity: error + category: object-type + critical: false + sentinel: ErrReservedKeywords + scope: + object_type: self + +# A condition applied to a relation that the model never defines. Scoped to the +# relation it is applied to rather than to a definition of its own, which is what +# separates relation-condition from condition. +- name: condition applied to a relation is not defined + dsl: | + model + schema 1.1 + type user + type document + relations + define viewer: [user with inRegion] + expected: + - error_type: condition-not-defined + severity: error + category: relation-condition + critical: false + sentinel: ErrConditionUndefined + scope: + object_type: document + relation: viewer + condition: inRegion diff --git a/pkg/go/validation/validation_engine.go b/pkg/go/validation/validation_engine.go index 31e616ea..2743138c 100644 --- a/pkg/go/validation/validation_engine.go +++ b/pkg/go/validation/validation_engine.go @@ -4,6 +4,8 @@ import ( "strings" openfgav1 "github.com/openfga/api/proto/openfga/v1" + + fgaerrors "github.com/openfga/language/pkg/go/errors" ) // ValidationEngine is the main entry point for all validation operations. @@ -41,20 +43,34 @@ func NewValidationEngine(model *openfgav1.AuthorizationModel, dslContent string) return ve } -// ValidateDSL validates a DSL model with all available validations. -func ValidateDSL(model *openfgav1.AuthorizationModel, dslContent string, options *EngineOptions) *ValidationErrors { +// ValidateDSL runs every validation over model, using dslContent to resolve each +// finding's position in the source text. The model is the already-parsed proto, +// here and in ValidateJSON; neither parses anything. +// +// Returns nil for a valid model. Otherwise the error is a *ValidationErrors, which +// errors.As recovers to list every finding; see ValidationErrors.ErrorOrNil for why +// a model carrying only warnings is nil here, and CreateValidationReport for reaching +// those findings. +func ValidateDSL(model *openfgav1.AuthorizationModel, dslContent string, options *EngineOptions) error { if options == nil { options = DefaultEngineOptions() } - return NewValidationEngine(model, dslContent).RunAllValidations(options) -} - -// ValidateJSON validates a JSON model. -func ValidateJSON(model *openfgav1.AuthorizationModel, options *EngineOptions) *ValidationErrors { + return NewValidationEngine(model, dslContent).RunAllValidations(options).ErrorOrNil() +} + +// ValidateJSON runs every validation over a model that reached the caller as JSON, +// so without the DSL source text behind it. It takes the same parsed proto as +// ValidateDSL and decodes no JSON itself; the name matches pkg/js's validateJSON and +// pkg/java's ModelValidator.validateJson. +// +// With no source text to resolve positions against, findings carry a nil Line and +// Column. The messages, categories and metadata are what ValidateDSL reports for the +// same model. Returns nil for a valid model, as ValidateDSL does. +func ValidateJSON(model *openfgav1.AuthorizationModel, options *EngineOptions) error { if options == nil { options = DefaultEngineOptions() } - return NewValidationEngine(model, "").RunAllValidations(options) + return NewValidationEngine(model, "").RunAllValidations(options).ErrorOrNil() } // RunAllValidations executes all validation phases in the correct order. @@ -64,21 +80,24 @@ func (ve *ValidationEngine) RunAllValidations(options *EngineOptions) *Validatio } // Schema and name validation run first and unconditionally. - ve.runSchemaValidation() - ve.runNameValidation() + ValidateSchemaVersion(ve.collector, ve.model, ve.lines) + ValidateNames(ve.collector, ve.model, ve.lines) // Relation-reference validation always runs. The phases that follow are - // gated on there being no errors yet: a model with bad references or + // gated on there being no blocking error 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. + // + // The gate counts blocking findings only, so a warning or advisory does not stop + // the later passes from finding an error that would invalidate the model. if !options.SkipSemanticValidation { validateRelationReferences(ve.collector, ve.semantic, ve.lines) } if !ve.collector.HasErrors() { - ve.runDuplicateDetection() + ValidateDuplicates(ve.collector, ve.model, ve.lines) } if !ve.collector.HasErrors() { @@ -97,54 +116,36 @@ func (ve *ValidationEngine) RunAllValidations(options *EngineOptions) *Validatio // 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() + ValidateMultiFileConsistency(ve.collector, ve.model, ve.lines) } if !options.SkipConditionValidation { - ve.runConditionValidation() + validateConditionReferences(ve.collector, ve.condition, ve.lines) + ValidateConditionConsistency(ve.collector, ve.model, ve.lines) + validateUnusedConditions(ve.collector, ve.condition, ve.lines) } - 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) + return NewValidationErrors(ve.collector.AllFindings()) } -// ValidateModel is a convenience function that validates a model with default options. -func ValidateModel(model *openfgav1.AuthorizationModel, dslContent string) *ValidationErrors { +// ValidateModel is ValidateDSL with the default options, which skip no phase. +func ValidateModel(model *openfgav1.AuthorizationModel, dslContent string) error { return ValidateDSL(model, dslContent, DefaultEngineOptions()) } -// ValidateModelJSON is a convenience function that validates a JSON model with default options. -func ValidateModelJSON(model *openfgav1.AuthorizationModel) *ValidationErrors { +// ValidateModelJSON is ValidateJSON with the default options. +func ValidateModelJSON(model *openfgav1.AuthorizationModel) error { return ValidateJSON(model, DefaultEngineOptions()) } func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { - errors := ve.collector.GetErrors() + errors := ve.collector.AllFindings() summary := ValidationSummary{ - TotalErrors: len(errors), - ErrorsByType: make(map[ValidationErrorType]int), - ErrorsByFile: make(map[string]int), - HasCriticalErrors: false, + TotalErrors: ve.collector.Count(), + TotalFindings: ve.collector.CountAll(), + ErrorsByType: make(map[ValidationErrorType]int), + ErrorsByFile: make(map[string]int), + FindingsBySeverity: make(map[fgaerrors.Severity]int), + HasCriticalErrors: false, } for _, err := range errors { if err == nil || err.Metadata == nil { @@ -156,7 +157,8 @@ func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { if err.File != "" { summary.ErrorsByFile[err.File]++ } - if ve.isCriticalError(err.Metadata.ErrorType) { + summary.FindingsBySeverity[err.Severity]++ + if isCriticalErrorType(err.Metadata.ErrorType) { summary.HasCriticalErrors = true } } @@ -164,30 +166,23 @@ func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { } // ValidationSummary provides a high-level overview of validation results. +// +// The breakdowns cover every finding, so they sum to TotalFindings, not TotalErrors. type ValidationSummary struct { - TotalErrors int - ErrorsByType map[ValidationErrorType]int - ErrorsByFile map[string]int - HasCriticalErrors bool -} + // TotalErrors counts only the findings that make the model invalid. + TotalErrors int -// 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, -} + // TotalFindings counts everything reported, including warnings and advisories. + TotalFindings int + + ErrorsByType map[ValidationErrorType]int + ErrorsByFile map[string]int -func (ve *ValidationEngine) isCriticalError(errorType ValidationErrorType) bool { - return criticalErrorTypes[errorType] + // FindingsBySeverity counts findings by severity. A finding with no severity set + // counts under SeverityUnspecified. + FindingsBySeverity map[fgaerrors.Severity]int + + HasCriticalErrors bool } // CreateValidationReport creates a detailed validation report. @@ -211,11 +206,23 @@ type ValidationReport struct { Options *EngineOptions } -func (vr *ValidationReport) IsValid() bool { return vr.ValidationErrors.Count() == 0 } +// IsValid reports whether the model is usable: no finding blocks it. Warnings and +// advisories leave it valid; HasFindings reports whether any were raised. +func (vr *ValidationReport) IsValid() bool { return !vr.ValidationErrors.HasErrors() } func (vr *ValidationReport) HasCriticalErrors() bool { return vr.Summary.HasCriticalErrors } + +// GetErrorsByType returns findings of a given error type, blocking or not: the +// caller has named the code it wants, so filtering by severity as well would drop +// matches it asked for. func (vr *ValidationReport) GetErrorsByType(errorType ValidationErrorType) []*ValidationError { var matchingErrors []*ValidationError - for _, err := range vr.ValidationErrors.GetErrors() { + for _, err := range vr.ValidationErrors.AllFindings() { + // The collector always sets metadata, but a directly-constructed finding + // need not have, and a code is only readable off metadata. + if err == nil || err.Metadata == nil { + continue + } + if err.Metadata.ErrorType == errorType { matchingErrors = append(matchingErrors, err) } diff --git a/pkg/go/validation/validation_engine_test.go b/pkg/go/validation/validation_engine_test.go index 48b28dd3..3043c284 100644 --- a/pkg/go/validation/validation_engine_test.go +++ b/pkg/go/validation/validation_engine_test.go @@ -1,14 +1,31 @@ package validation import ( + "errors" "fmt" "testing" - "github.com/stretchr/testify/assert" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" + "github.com/openfga/language/pkg/go/transformer" ) -// TestValidationEngine_BasicIntegration tests the basic integration of all validation components +// findingsFrom recovers the collection behind an error returned by a validation entry +// point. A nil error becomes an empty collection, so a test can read Count and +// GetErrors off the result either way. +func findingsFrom(err error) *ValidationErrors { + var validationErrors *ValidationErrors + if errors.As(err, &validationErrors) { + return validationErrors + } + + return NewValidationErrors(nil) +} + +// 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{ @@ -62,24 +79,11 @@ type document 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()) + // A valid model reports nothing, from every entry point. + assert.NoError(t, ValidateDSL(model, dslContent, DefaultEngineOptions())) + assert.NoError(t, ValidateJSON(model, DefaultEngineOptions())) + assert.NoError(t, ValidateModel(model, dslContent)) + assert.NoError(t, ValidateModelJSON(model)) }) t.Run("Model with validation errors", func(t *testing.T) { @@ -120,23 +124,22 @@ type document define admin: [user] ` - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) - assert.Greater(t, errors.Count(), 0) + findings := findingsFrom(ValidateDSL(model, dslContent, DefaultEngineOptions())) + assert.Positive(t, findings.Count()) // Check that we have various types of errors - errorList := errors.GetErrors() + errorList := findings.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") + assert.NotEmpty(t, errorTypes, "Should have validation errors") }) } -// TestValidationEngine_OptionsConfiguration tests different validation options +// TestValidationEngine_OptionsConfiguration tests different validation options. func TestValidationEngine_OptionsConfiguration(t *testing.T) { t.Run("Skip semantic validation", func(t *testing.T) { model := &openfgav1.AuthorizationModel{ @@ -154,18 +157,16 @@ func TestValidationEngine_OptionsConfiguration(t *testing.T) { } // With semantic validation (default) - normalErrors := ValidateDSL(model, "", DefaultEngineOptions()) - normalErrorCount := normalErrors.Count() + normalErrorCount := findingsFrom(ValidateDSL(model, "", DefaultEngineOptions())).Count() // Skip semantic validation options := &EngineOptions{ SkipSemanticValidation: true, } - skippedErrors := ValidateDSL(model, "", options) - skippedErrorCount := skippedErrors.Count() + skippedErrorCount := findingsFrom(ValidateDSL(model, "", options)).Count() // Should have fewer errors when semantic validation is skipped - assert.True(t, skippedErrorCount <= normalErrorCount, "Skipping semantic validation should reduce or maintain error count") + assert.LessOrEqual(t, skippedErrorCount, normalErrorCount, "Skipping semantic validation should reduce or maintain error count") }) t.Run("Skip complex operation validation", func(t *testing.T) { @@ -191,13 +192,18 @@ func TestValidationEngine_OptionsConfiguration(t *testing.T) { options := &EngineOptions{ SkipComplexOperationValidation: true, } - errors := ValidateDSL(model, "", options) - assert.NotNil(t, errors) - // Complex operation validation should be skipped + + // Skipping complex-operation validation drops findings, never adds them, and + // leaves the surrounding phases running. + normalErrorCount := findingsFrom(ValidateDSL(model, "", DefaultEngineOptions())).Count() + skippedErrorCount := findingsFrom(ValidateDSL(model, "", options)).Count() + + assert.LessOrEqual(t, skippedErrorCount, normalErrorCount, + "Skipping complex operation validation should reduce or maintain error count") }) } -// TestValidationReport tests the comprehensive validation report functionality +// TestValidationReport tests the comprehensive validation report functionality. func TestValidationReport(t *testing.T) { t.Run("Complete validation report", func(t *testing.T) { model := &openfgav1.AuthorizationModel{ @@ -275,20 +281,20 @@ type document if report.ValidationErrors.Count() > 0 { assert.False(t, report.IsValid(), "Invalid model should fail IsValid()") - + summary := report.Summary - assert.Greater(t, summary.TotalErrors, 0) - + assert.Positive(t, summary.TotalErrors) + // 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) + assert.NotEmpty(t, errorsOfType, "Should find errors of type %s", errorType) } } }) } -// TestValidationEngine_RealWorldScenarios tests realistic authorization model scenarios +// 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{ @@ -330,7 +336,7 @@ func TestValidationEngine_RealWorldScenarios(t *testing.T) { Child: []*openfgav1.Userset{ {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, {Userset: &openfgav1.Userset_TupleToUserset{TupleToUserset: &openfgav1.TupleToUserset{ - Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, + Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, ComputedUserset: &openfgav1.ObjectRelation{Relation: "member"}, }}}, }, @@ -349,7 +355,7 @@ func TestValidationEngine_RealWorldScenarios(t *testing.T) { Child: []*openfgav1.Userset{ {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, {Userset: &openfgav1.Userset_TupleToUserset{TupleToUserset: &openfgav1.TupleToUserset{ - Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, + Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, ComputedUserset: &openfgav1.ObjectRelation{Relation: "owner"}, }}}, }, @@ -407,13 +413,12 @@ type repository define reader: [user] or writer from owner ` - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) + findings := findingsFrom(ValidateDSL(model, dslContent, DefaultEngineOptions())) // This complex model should pass validation - if errors.Count() > 0 { - t.Logf("Validation errors found: %d", errors.Count()) - for _, err := range errors.GetErrors() { + if findings.Count() > 0 { + t.Logf("Validation errors found: %d", findings.Count()) + for _, err := range findings.GetErrors() { t.Logf("Error: %s (Type: %s)", err.Message, err.Metadata.ErrorType) } } @@ -429,21 +434,21 @@ type repository }) } -// TestValidationEngine_PerformanceBasics tests basic performance characteristics +// 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{}}, @@ -453,7 +458,7 @@ func TestValidationEngine_PerformanceBasics(t *testing.T) { {Type: "user"}, }, } - + // Add editor relation with union relations["editor"] = &openfgav1.Userset{ Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ @@ -468,7 +473,7 @@ func TestValidationEngine_PerformanceBasics(t *testing.T) { {Type: "user"}, }, } - + typeDefs = append(typeDefs, &openfgav1.TypeDefinition{ Type: typeName, Relations: relations, @@ -484,15 +489,130 @@ func TestValidationEngine_PerformanceBasics(t *testing.T) { } // Test validation performance - errors := ValidateDSL(model, "", DefaultEngineOptions()) - assert.NotNil(t, errors) + findings := findingsFrom(ValidateDSL(model, "", DefaultEngineOptions())) // Should complete validation in reasonable time - t.Logf("Large model validation completed with %d errors", errors.Count()) - + t.Logf("Large model validation completed with %d errors", findings.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()) + jsonFindings := findingsFrom(ValidateJSON(model, DefaultEngineOptions())) + t.Logf("Large model JSON validation completed with %d errors", jsonFindings.Count()) }) } + +// TestEntryPointsReportFindingsThroughTheError pins what the four entry points +// return: nil for a valid model, and otherwise an error carrying every finding with +// its sentinel and its scope still reachable, which is what findingsFrom relies on. +func TestEntryPointsReportFindingsThroughTheError(t *testing.T) { + t.Parallel() + + const dsl = `model + schema 1.1 +type user +type document + relations + define viewer: [user, group] +` + + model, err := transformer.TransformDSLToProto(dsl) + require.NoError(t, err) + + validationErr := ValidateDSL(model, dsl, DefaultEngineOptions()) + require.Error(t, validationErr, "group is not defined, so this model must not validate") + + // errors.Is reaches each finding's sentinel through Unwrap() []error. + require.ErrorIs(t, validationErr, fgaerrors.ErrInvalidType) + + // errors.As reaches the scope by the same path. + var scoped *fgaerrors.ErrObjectType + require.ErrorAs(t, validationErr, &scoped) + assert.Equal(t, "group", scoped.ObjectType) + + // errors.As also recovers the collection itself, which is how a caller lists every + // finding rather than the first one errors.As stops at. + var collection *ValidationErrors + require.ErrorAs(t, validationErr, &collection) + assert.NotEmpty(t, collection.AllFindings()) +} + +// TestFindingOrderIsDeterministic checks that validating one model twice reports its +// findings in the same order. +// +// Relations and conditions reach every validation phase in a proto map, which has no +// order of its own. Ranging one directly ordered the findings by whatever the runtime +// handed back, so the same model produced four different orders across runs, and a +// caller printing the list or comparing it against a fixture saw it change under them. +func TestFindingOrderIsDeterministic(t *testing.T) { + t.Parallel() + + // The relation names sort in a different order than the symbols they report, so a + // run that happened to sort by message would not pass this. + const dsl = `model + schema 1.1 +type user +type document + relations + define alpha: missing_a + define beta: missing_b + define gamma: missing_c + define delta: missing_d +` + + model, err := transformer.TransformDSLToProto(dsl) + require.NoError(t, err) + + want := []string{ + "the relation `missing_a` does not exist.", + "the relation `missing_b` does not exist.", + "the relation `missing_d` does not exist.", + "the relation `missing_c` does not exist.", + } + + // Map iteration is randomized per range, so one passing run proves nothing; four + // keys admit four orders, which 100 runs would not agree on by chance. + for i := 0; i < 100; i++ { + messages := make([]string, 0, len(want)) + for _, finding := range findingsFrom(ValidateDSL(model, dsl, nil)).AllFindings() { + messages = append(messages, finding.Message) + } + + require.Equal(t, want, messages, "run %d reported the findings in a different order", i) + } +} + +// TestValidateJSONDiffersFromValidateDSLOnlyInPosition checks the two entry points +// take the same parsed proto and report the same findings, and that position is the +// only difference: ValidateDSL resolves line and column from the source text, and +// ValidateJSON leaves both nil. +func TestValidateJSONDiffersFromValidateDSLOnlyInPosition(t *testing.T) { + t.Parallel() + + const dsl = `model + schema 1.1 +type user +type document + relations + define viewer: [user, group] +` + + model, err := transformer.TransformDSLToProto(dsl) + require.NoError(t, err) + + fromDSL := findingsFrom(ValidateDSL(model, dsl, nil)).AllFindings() + fromJSON := findingsFrom(ValidateJSON(model, nil)).AllFindings() + + require.NotEmpty(t, fromDSL, "the model must produce a finding, or this compares two empty lists") + require.Len(t, fromJSON, len(fromDSL), "the two entry points must find the same problems") + + for i := range fromDSL { + assert.Equal(t, fromDSL[i].Message, fromJSON[i].Message) + assert.Equal(t, fromDSL[i].Severity, fromJSON[i].Severity) + assert.Equal(t, fromDSL[i].Category, fromJSON[i].Category) + assert.Equal(t, fromDSL[i].Metadata, fromJSON[i].Metadata) + + assert.NotNil(t, fromDSL[i].Line, "ValidateDSL has the source text, so it must resolve the line") + assert.NotNil(t, fromDSL[i].Column) + assert.Nil(t, fromJSON[i].Line, "ValidateJSON has no source text to resolve a line against") + assert.Nil(t, fromJSON[i].Column) + } +} diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go index 8a4ea4e5..238bff71 100644 --- a/pkg/go/validation/wildcard_validation.go +++ b/pkg/go/validation/wildcard_validation.go @@ -2,6 +2,8 @@ package validation import ( "fmt" + "maps" + "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -23,8 +25,10 @@ func validateWildcardUsage(collector *ErrorCollector, validator *SemanticValidat if typeDef.GetMetadata() == nil { continue } - for relationName, relationMetadata := range typeDef.GetMetadata().GetRelations() { - validateWildcardInRelation(collector, validator, typeDef.GetType(), relationName, relationMetadata, lines) + relationsMetadata := typeDef.GetMetadata().GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + validateWildcardInRelation(collector, validator, typeDef.GetType(), relationName, + relationsMetadata[relationName], lines) } } } @@ -79,8 +83,10 @@ func validateTupleToUsersetRequirements(collector *ErrorCollector, validator *Se return } for _, typeDef := range model.GetTypeDefinitions() { - for relationName, userset := range typeDef.GetRelations() { - validateTupleToUsersetInUserset(collector, validator, typeDef.GetType(), relationName, userset, lines) + relations := typeDef.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + validateTupleToUsersetInUserset(collector, validator, typeDef.GetType(), relationName, + relations[relationName], lines) } } } @@ -149,11 +155,19 @@ func validateTuplesetDirectAssignment(collector *ErrorCollector, validator *Sema 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) + // The wildcard is written in a relation of parentTypeName; typeName is the + // restriction it appears in, which the symbol already records. + c.addScopedError(message, InvalidWildcardError, typeName, lineIndex, meta, nil, scope{ + objectType: parentTypeName, + relation: relationName, + }) } 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) + c.addScopedError(message, TuplesetNotDirect, tuplesetRelation, lineIndex, meta, nil, scope{ + objectType: typeName, + relation: tuplesetRelation, + }) } diff --git a/pkg/go/validation/yaml_integration_test.go b/pkg/go/validation/yaml_integration_test.go index 81756e27..51be6b98 100644 --- a/pkg/go/validation/yaml_integration_test.go +++ b/pkg/go/validation/yaml_integration_test.go @@ -4,363 +4,217 @@ 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(*ValidationError)) *ValidationError { + found := &ValidationError{ + Message: "the relation `viewer` does not exist.", + Line: &Range{Start: 4, End: 4}, + Column: &Range{Start: 12, End: 18}, + Metadata: &ErrorMetadata{ + Symbol: "viewer", + ErrorType: 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 []*ValidationError + problems int + }{ + { + name: "match", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{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: []*ValidationError{finding(func(f *ValidationError) { + f.Message += " Did you mean `view`?" + })}, + problems: 2, + }, + { + name: "wrong line", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(func(f *ValidationError) { + f.Line = &Range{Start: 5, End: 5} + })}, + problems: 1, + }, + { + name: "line end differs", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(func(f *ValidationError) { + f.Line = &Range{Start: 4, End: 6} + })}, + problems: 1, + }, + { + name: "no position at all", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(func(f *ValidationError) { + f.Line, f.Column = nil, nil + })}, + problems: 1, + }, + { + name: "wrong column", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(func(f *ValidationError) { + f.Column = &Range{Start: 12, End: 17} + })}, + problems: 1, + }, + { + name: "wrong symbol", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(func(f *ValidationError) { + f.Metadata.Symbol = "editor" + })}, + problems: 1, + }, + { + name: "wrong error type", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(func(f *ValidationError) { + f.Metadata.ErrorType = UndefinedRelation + })}, + problems: 1, + }, + { + name: "no metadata", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(func(f *ValidationError) { + f.Metadata = nil + })}, + problems: 1, + }, + { + name: "one finding does not satisfy two expectations", + expected: []YAMLExpectedError{expected, expected}, + findings: []*ValidationError{finding(nil)}, + problems: 1, + }, + { + name: "finding the corpus does not expect", + expected: []YAMLExpectedError{expected}, + findings: []*ValidationError{finding(nil), finding(func(f *ValidationError) { + 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: []*ValidationError{finding(func(f *ValidationError) { + 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, NewValidationErrors(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..29075109 100644 --- a/pkg/go/validation/yaml_test_integration_test.go +++ b/pkg/go/validation/yaml_test_integration_test.go @@ -4,61 +4,67 @@ 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 +// 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" +) + +// 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"` - Metadata map[string]interface{} `yaml:"metadata,omitempty"` + Name string `yaml:"name"` + DSL string `yaml:"dsl"` + Skip bool `yaml:"skip,omitempty"` + ExpectedErrors []YAMLExpectedError `yaml:"expected_errors,omitempty"` } -// YAMLExpectedError represents an expected validation error from YAML test files +// 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 YAMLLineRange `yaml:"line,omitempty"` - Column YAMLColumnRange `yaml:"column,omitempty"` - Metadata YAMLErrorMetadata `yaml:"metadata,omitempty"` -} - -// YAMLLineRange represents line start and end positions -type YAMLLineRange struct { - Start int `yaml:"start"` - End int `yaml:"end"` + 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. Severity and category are this package's own classification, the corpus +// states neither, and the severity fixtures cover them instead. 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 +72,203 @@ 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, findingsFrom(ValidateDSL(model, testCase.DSL, DefaultEngineOptions()))) } -// 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. +// +// The blocking findings are what take part: the corpus states the errors that make a +// model invalid and carries no severity of its own, so a warning is neither expected +// nor unexpected here. +func compareWithCorpus(expectedErrors []YAMLExpectedError, findings *ValidationErrors) *YAMLTestResult { + result := &YAMLTestResult{} + blocking := findings.GetErrors() + claimed := make([]bool, len(blocking)) + 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 blocking { + 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 blocking { + 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))) } } - - // Determine overall status - if len(result.ErrorDetails) == 0 { - result.Status = "PASS" - result.Message = fmt.Sprintf("All %d errors matched correctly", expectedCount) + + for j, finding := range blocking { + if !claimed[j] { + result.Problems = append(result.Problems, fmt.Sprintf("unexpected finding %s", describeFinding(finding))) + } + } + + 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 - } - - // Check error type if specified - if expected.Metadata.ErrorType != "" { - expectedType := ValidationErrorType(expected.Metadata.ErrorType) - if actual.Metadata.ErrorType != expectedType { - 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 *ValidationError) string { + if finding.Message != expected.Message { + return fmt.Sprintf("message %q, want %q", finding.Message, expected.Message) } - - // Check line numbers if specified - if expected.Line.Start > 0 && actual.Line != nil { - if actual.Line.Start != expected.Line.Start { - return false - } + + if finding.Metadata == nil { + return "no metadata" } - - // Check column numbers if specified - if expected.Column.Start > 0 && actual.Column != nil { - if actual.Column.Start != expected.Column.Start { - return false - } + + if expected.Metadata.ErrorType != "" && + string(finding.Metadata.ErrorType) != expected.Metadata.ErrorType { + return fmt.Sprintf("errorType %q, want %q", finding.Metadata.ErrorType, expected.Metadata.ErrorType) } - - return true -} -// 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 expected.Metadata.Symbol != "" && finding.Metadata.Symbol != expected.Metadata.Symbol { + return fmt.Sprintf("symbol %q, want %q", finding.Metadata.Symbol, expected.Metadata.Symbol) } - if model == nil { - return nil, fmt.Errorf("failed to parse DSL") + + // A position the corpus states has to be reached, both ends of it. Letting a + // finding without one through would pass a finding that resolved to nowhere in the + // source, which is how the schema line lookup went unnoticed. + if problem := describeRangeMismatch("line", expected.Line, finding.Line); problem != "" { + return problem } - return model, nil + + return describeRangeMismatch("column", expected.Column, finding.Column) } -// 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 +func describeRangeMismatch(name string, expected *YAMLRange, actual *Range) string { + if expected == nil { + return "" } - - 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 == nil { + return "no " + name } - - return results, nil + + 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 "" } -// GenerateTestReport generates a comprehensive test report -func (runner *YAMLTestRunner) GenerateTestReport(results map[string][]*YAMLTestResult) *YAMLTestReport { - report := &YAMLTestReport{ - SuiteResults: results, - Summary: make(map[string]int), +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 *ValidationError) string { + errorType := ValidationErrorType("") + if finding.Metadata != nil { + errorType = finding.Metadata.ErrorType } - - totalTests := 0 - for _, suiteResults := range results { - for _, result := range suiteResults { - totalTests++ - report.Summary[result.Status]++ - } + + return fmt.Sprintf("%q [%s]%s", finding.Message, errorType, 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..d7a53a42 100644 --- a/tests/data/dsl-semantic-validation-cases.yaml +++ b/tests/data/dsl-semantic-validation-cases.yaml @@ -1,4 +1,16 @@ --- +# 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. +# +# pkg/go attaches severity, category and criticality to each finding. Those +# expectations live in pkg/go/validation/testdata/severity-category-cases.yaml until +# the other implementations have the fields, and a test keeps them out of this file. - name: model 1.1 diff in exclusion not valid and spaces are reflected correctly in error messages dsl: | model From e5663ec6ff82d3e9e52027ac0e37ee59aae7a6f4 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 21 Aug 2026 20:23:38 +0530 Subject: [PATCH 2/8] refactor(pkg/go): let each raise site name the part of the model at fault A finding's category no longer comes from a per-code table default that a raise site overrides. The code does not determine which part of a model is at fault: duplicated-error is raised about a type from one place and a relation from five, and invalid-name about a type, a relation and a condition. Each raise site now builds the scoped error itself and the collector reads the category and the metadata back off it, so newScopedCause, the errors.As chain that read the scope back, the category override and errorInfo.Category are all gone. The five scope types implement a ModelError interface with Kind and Scope, which is what makes reading the category off the cause possible. Its unexported method seals it, so those five are the only implementations. WithSentinel supplies the sentinel separately, which keeps errorInfoByType the only place a code's sentinel is decided while the raise site decides the scope. ValidationError.Cause is that interface rather than a bare error. The field a raise site fills in is called part rather than cause. Cause elsewhere in this package means the error a thing wraps: errorInfo.Cause is the sentinel a code implies, ErrRelation.Cause the sentinel a scope holds, ValidationError.Cause the error a finding wraps. This one holds a part of the model and is given its sentinel afterwards, so it is named for what it holds. Severity and ModelErrorKind resolve their wire names through a switch instead of a pair of package-level maps built by an init closure. The reverse direction is written out rather than derived from the forward one, and the wire-name round trip covers every declared value, so the two cannot drift apart unnoticed. unemittedErrorTypes and allErrorTypes are read only by tests, so they live with them. Only the map cost anything in a shipped binary, since a slice of constants is eliminated, but neither belongs in the package's non-test sources. Emitted findings are unchanged. Calling every Raise method, and validating every case in the DSL corpus, produces byte-identical messages, severities, categories, positions, metadata and sentinel matches. --- pkg/go/errors/doc.go | 3 + pkg/go/errors/model_error.go | 132 +++++++++ pkg/go/errors/model_error_kind.go | 71 +++-- pkg/go/errors/severity.go | 61 +++-- pkg/go/validation/error_collector.go | 252 +++++++----------- pkg/go/validation/error_info.go | 101 +------ .../validation/error_info_integration_test.go | 26 +- pkg/go/validation/error_info_test.go | 75 +++++- pkg/go/validation/errors.go | 6 +- pkg/go/validation/severity_fixtures_test.go | 8 +- pkg/go/validation/severity_predicates_test.go | 23 ++ pkg/go/validation/wildcard_validation.go | 8 +- 12 files changed, 442 insertions(+), 324 deletions(-) diff --git a/pkg/go/errors/doc.go b/pkg/go/errors/doc.go index 68c01f37..dcf74db6 100644 --- a/pkg/go/errors/doc.go +++ b/pkg/go/errors/doc.go @@ -7,6 +7,9 @@ // errors.As: ErrObjectType, ErrRelation, ErrRelationCondition, ErrCondition, or // ErrModel when no single part is responsible. // +// Those five are the implementations of ModelError, so a caller that does not care +// which one it holds can read Kind and Scope off the interface instead. +// // For a consumer that sees only serialised output, ModelErrorKind is the scope as // a name and Severity is whether the finding blocks. Both reserve zero for "not // set" and serialise as their name, so the names are API and the numbers are not. diff --git a/pkg/go/errors/model_error.go b/pkg/go/errors/model_error.go index 8bfe00b6..45a718fe 100644 --- a/pkg/go/errors/model_error.go +++ b/pkg/go/errors/model_error.go @@ -16,6 +16,71 @@ import "fmt" // error name here is Err-prefixed, types as well as sentinel values, so each type // below opts out of errname's XxxError rule. +// ModelError is the cause of a validation finding: a sentinel naming the problem, +// wrapped in the part of the model it was found in. +// +// A caller reaches the problem with errors.Is, and the part of the model either +// with errors.As on one of the concrete types, or through Kind and Scope when the +// concrete type does not matter: +// +// var modelErr errors.ModelError +// if errors.As(err, &modelErr) { +// fmt.Println(modelErr.Kind(), modelErr.Scope().Relation) +// } +// +// The interface has an unexported method, so the five types below are its only +// implementations and a Kind always corresponds to one of them. +type ModelError interface { + error + + // Kind reports which part of the model this finding is attached to. + Kind() ModelErrorKind + + // Scope names that part. Which fields are set follows from Kind; the rest are + // empty. + Scope() ModelErrorScope + + // Unwrap returns the sentinel, so errors.Is reaches it through this error. + Unwrap() error + + // withSentinel returns a copy reporting sentinel as the problem, leaving the + // receiver alone. WithSentinel is the exported way in. + withSentinel(sentinel error) ModelError +} + +// ModelErrorScope names the part of a model a finding is attached to, for a caller +// that wants the names without switching on the concrete type. A finding about the +// model as a whole has none of the three set. +type ModelErrorScope struct { + ObjectType string + Relation string + Condition string +} + +// WithSentinel returns err reporting sentinel as the problem it names, leaving err +// unchanged. It lets the part of the model at fault and the problem be decided in +// different places: whoever finds the fault builds the scope, and whichever code is +// being raised supplies the sentinel. +// +// A nil sentinel yields nil rather than an error whose message reports nothing. +func WithSentinel(err ModelError, sentinel error) ModelError { + if err == nil || sentinel == nil { + return nil + } + + return err.withSentinel(sentinel) +} + +// Every scope type is a ModelError; a new one that forgets a method fails to build +// here rather than at whichever call site first needs it. +var ( + _ ModelError = (*ErrObjectType)(nil) + _ ModelError = (*ErrRelation)(nil) + _ ModelError = (*ErrRelationCondition)(nil) + _ ModelError = (*ErrCondition)(nil) + _ ModelError = (*ErrModel)(nil) +) + // ErrObjectType is a finding about an object type as a whole. // //nolint:errname // Err-prefixed by convention here; see the naming note above @@ -32,6 +97,18 @@ func (e *ErrObjectType) Unwrap() error { return e.Cause } +func (e *ErrObjectType) Kind() ModelErrorKind { + return ErrorKindObjectType +} + +func (e *ErrObjectType) Scope() ModelErrorScope { + return ModelErrorScope{ObjectType: e.ObjectType} +} + +func (e *ErrObjectType) withSentinel(sentinel error) ModelError { + return &ErrObjectType{ObjectType: e.ObjectType, Cause: sentinel} +} + // ErrRelation is a finding about a relation on an object type. // //nolint:errname // Err-prefixed by convention here; see the naming note above @@ -54,6 +131,18 @@ func (e *ErrRelation) Unwrap() error { return e.Cause } +func (e *ErrRelation) Kind() ModelErrorKind { + return ErrorKindRelation +} + +func (e *ErrRelation) Scope() ModelErrorScope { + return ModelErrorScope{ObjectType: e.ObjectType, Relation: e.Relation} +} + +func (e *ErrRelation) withSentinel(sentinel error) ModelError { + return &ErrRelation{ObjectType: e.ObjectType, Relation: e.Relation, Cause: sentinel} +} + // ErrRelationCondition is a finding about a condition as applied to one relation, // rather than about the condition's own definition. // @@ -74,6 +163,23 @@ func (e *ErrRelationCondition) Unwrap() error { return e.Cause } +func (e *ErrRelationCondition) Kind() ModelErrorKind { + return ErrorKindRelationCondition +} + +func (e *ErrRelationCondition) Scope() ModelErrorScope { + return ModelErrorScope{ObjectType: e.ObjectType, Relation: e.Relation, Condition: e.Condition} +} + +func (e *ErrRelationCondition) withSentinel(sentinel error) ModelError { + return &ErrRelationCondition{ + ObjectType: e.ObjectType, + Relation: e.Relation, + Condition: e.Condition, + Cause: sentinel, + } +} + // ErrCondition is a finding about a condition definition itself, independent of // where it is applied. // @@ -91,6 +197,18 @@ func (e *ErrCondition) Unwrap() error { return e.Cause } +func (e *ErrCondition) Kind() ModelErrorKind { + return ErrorKindCondition +} + +func (e *ErrCondition) Scope() ModelErrorScope { + return ModelErrorScope{Condition: e.Condition} +} + +func (e *ErrCondition) withSentinel(sentinel error) ModelError { + return &ErrCondition{Condition: e.Condition, Cause: sentinel} +} + // ErrModel is a finding about the model as a whole, which cannot be attributed // to a single type, relation or condition. // @@ -106,3 +224,17 @@ func (e *ErrModel) Error() string { func (e *ErrModel) Unwrap() error { return e.Cause } + +func (e *ErrModel) Kind() ModelErrorKind { + return ErrorKindInvalidModel +} + +// Scope returns an empty scope: a finding about the model as a whole names no type, +// relation or condition, which is what distinguishes it from the other four. +func (e *ErrModel) Scope() ModelErrorScope { + return ModelErrorScope{} +} + +func (e *ErrModel) withSentinel(sentinel error) ModelError { + return &ErrModel{Cause: sentinel} +} diff --git a/pkg/go/errors/model_error_kind.go b/pkg/go/errors/model_error_kind.go index dab75f84..9b780729 100644 --- a/pkg/go/errors/model_error_kind.go +++ b/pkg/go/errors/model_error_kind.go @@ -35,30 +35,51 @@ const ( ErrorKindInvalidModel ) -// modelErrorKindNames maps each category to its wire name. A category missing -// from here fails to marshal, so a constant added without a name is caught. -var modelErrorKindNames = map[ModelErrorKind]string{ - ErrorKindObjectType: "object-type", - ErrorKindRelation: "relation", - ErrorKindRelationCondition: "relation-condition", - ErrorKindCondition: "condition", - ErrorKindInvalidModel: "invalid-model", +// wireName returns the name a category serialises as, and an empty string for a +// value with no name. It is the only place the mapping lives, so String, IsValid +// and MarshalText cannot disagree about which values have one. +func (m ModelErrorKind) wireName() string { + switch m { + case ErrorKindObjectType: + return "object-type" + case ErrorKindRelation: + return "relation" + case ErrorKindRelationCondition: + return "relation-condition" + case ErrorKindCondition: + return "condition" + case ErrorKindInvalidModel: + return "invalid-model" + default: + // ModelErrorKindUnspecified lands here too: the zero value has no name by + // design, so it marshals as a failure rather than as a category. + return "" + } } -// modelErrorKindValues is the reverse of modelErrorKindNames, built from it so -// the two cannot disagree. -var modelErrorKindValues = func() map[string]ModelErrorKind { - values := make(map[string]ModelErrorKind, len(modelErrorKindNames)) - for errorType, name := range modelErrorKindNames { - values[name] = errorType +// modelErrorKindFromName is the reverse of wireName. The two are written out +// separately rather than derived from one another, so TestModelErrorKindWireNames +// round trips every declared category to keep them in step. +func modelErrorKindFromName(name string) (ModelErrorKind, bool) { + switch name { + case "object-type": + return ErrorKindObjectType, true + case "relation": + return ErrorKindRelation, true + case "relation-condition": + return ErrorKindRelationCondition, true + case "condition": + return ErrorKindCondition, true + case "invalid-model": + return ErrorKindInvalidModel, true + default: + return ModelErrorKindUnspecified, false } - - return values -}() +} // String returns the wire name, or a diagnostic form for a value with none. func (m ModelErrorKind) String() string { - if name, ok := modelErrorKindNames[m]; ok { + if name := m.wireName(); name != "" { return name } @@ -71,15 +92,17 @@ func (m ModelErrorKind) String() string { // IsValid reports whether m is a declared category with a wire name. func (m ModelErrorKind) IsValid() bool { - _, ok := modelErrorKindNames[m] - - return ok + return m.wireName() != "" } // MarshalText emits the wire name, so the JSON carries "object-type". +// +// It does not go through String, because String has a diagnostic form for an +// undeclared number and this has to have none: a category that cannot be named +// must fail to marshal rather than ship as ModelErrorKind(99). func (m ModelErrorKind) MarshalText() ([]byte, error) { - name, ok := modelErrorKindNames[m] - if !ok { + name := m.wireName() + if name == "" { return nil, fmt.Errorf("%w: %d", ErrUnknownModelErrorKind, int(m)) } @@ -89,7 +112,7 @@ func (m ModelErrorKind) MarshalText() ([]byte, error) { // UnmarshalText resolves a wire name back to its category, rejecting any name // this package does not declare. func (m *ModelErrorKind) UnmarshalText(text []byte) error { - errorType, ok := modelErrorKindValues[string(text)] + errorType, ok := modelErrorKindFromName(string(text)) if !ok { return fmt.Errorf("%w: %q", ErrUnknownModelErrorKind, text) } diff --git a/pkg/go/errors/severity.go b/pkg/go/errors/severity.go index 41c68c14..93b27485 100644 --- a/pkg/go/errors/severity.go +++ b/pkg/go/errors/severity.go @@ -28,28 +28,43 @@ const ( SeverityAdvisory ) -// severityNames maps each severity to its wire name. A severity missing from here -// fails to marshal, so a constant added without a name is caught. -var severityNames = map[Severity]string{ - SeverityError: "error", - SeverityWarning: "warning", - SeverityAdvisory: "advisory", +// wireName returns the name a severity serialises as, and an empty string for a +// value with no name. It is the only place the mapping lives, so String, IsValid +// and MarshalText cannot disagree about which values have one. +func (s Severity) wireName() string { + switch s { + case SeverityError: + return "error" + case SeverityWarning: + return "warning" + case SeverityAdvisory: + return "advisory" + default: + // SeverityUnspecified lands here too: the zero value has no name by design, + // so it marshals as a failure rather than as a severity. + return "" + } } -// severityValues is the reverse of severityNames, built from it so the two -// cannot disagree. -var severityValues = func() map[string]Severity { - values := make(map[string]Severity, len(severityNames)) - for severity, name := range severityNames { - values[name] = severity +// severityFromName is the reverse of wireName. The two are written out separately +// rather than derived from one another, so TestSeverityWireNames round trips every +// declared severity to keep them in step. +func severityFromName(name string) (Severity, bool) { + switch name { + case "error": + return SeverityError, true + case "warning": + return SeverityWarning, true + case "advisory": + return SeverityAdvisory, true + default: + return SeverityUnspecified, false } - - return values -}() +} // String returns the wire name, or a diagnostic form for a value with none. func (s Severity) String() string { - if name, ok := severityNames[s]; ok { + if name := s.wireName(); name != "" { return name } @@ -62,9 +77,7 @@ func (s Severity) String() string { // IsValid reports whether s is a declared severity with a wire name. func (s Severity) IsValid() bool { - _, ok := severityNames[s] - - return ok + return s.wireName() != "" } // Blocks reports whether a finding of this severity makes validation fail. @@ -77,9 +90,13 @@ func (s Severity) Blocks() bool { } // MarshalText emits the wire name, so the JSON carries "warning". +// +// It does not go through String, because String has a diagnostic form for an +// undeclared number and this has to have none: a severity that cannot be named must +// fail to marshal rather than ship as Severity(99). func (s Severity) MarshalText() ([]byte, error) { - name, ok := severityNames[s] - if !ok { + name := s.wireName() + if name == "" { return nil, fmt.Errorf("%w: %d", ErrUnknownSeverity, int(s)) } @@ -89,7 +106,7 @@ func (s Severity) MarshalText() ([]byte, error) { // UnmarshalText resolves a wire name back to its severity, rejecting any name // this package does not declare. func (s *Severity) UnmarshalText(text []byte) error { - severity, ok := severityValues[string(text)] + severity, ok := severityFromName(string(text)) if !ok { return fmt.Errorf("%w: %q", ErrUnknownSeverity, text) } diff --git a/pkg/go/validation/error_collector.go b/pkg/go/validation/error_collector.go index 70a48ea9..5b68ddd1 100644 --- a/pkg/go/validation/error_collector.go +++ b/pkg/go/validation/error_collector.go @@ -1,7 +1,6 @@ package validation import ( - "errors" "fmt" "strings" @@ -106,79 +105,57 @@ func (c *ErrorCollector) CountAll() int { return len(c.errors) } -// scope names the model entity a finding is about, so addScopedError can build the -// cause and derive the metadata from one description. A zero scope means the raise -// site has nothing to add beyond the symbol, and the table's category stands alone. +// scope is what a raise site knows that the collector cannot work out: which part of +// the model is at fault, and the enclosing type for the metadata. type scope struct { - objectType string - relation string - condition string + // part names the part of the model at fault. The raise site builds it, because + // the code alone does not say which part: duplicated-error is raised about a type + // from one place and a relation from another, and invalid-name about all three. + // The sentinel is filled in from the code's table entry, so at this point it + // wraps nothing. + part fgaerrors.ModelError // offendingType is the enclosing type a finding about another type was written - // in, matching JS's wire field of the same name. Metadata only: no scoped error - // type has a slot for it. + // in, matching JS's wire field of the same name. Metadata only: none of the + // scope types has a slot for it. offendingType string - - // category overrides the table default when set, for codes raised from places - // with different scopes: a duplicate type and a duplicate type restriction share - // one code without being the same kind of finding. - category fgaerrors.ModelErrorKind } -// addError is a helper to add an error to the collection. +// addError adds a finding that names no part of the model, so it is about the model as +// a whole. A raise site that names one, carries file or module metadata, or resolves +// its own column goes through addScopedError instead; none of the codes raised through +// here does any of those. func (c *ErrorCollector) addError(message string, errorType ValidationErrorType, symbol string, - lineIndex *int, meta *Meta, customResolver ErrorCustomResolver) { - c.addScopedError(message, errorType, symbol, lineIndex, meta, customResolver, scope{}) + lineIndex *int) { + c.addScopedError(message, errorType, symbol, lineIndex, nil, nil, scope{ + part: &fgaerrors.ErrModel{}, + }) } -// addScopedError adds an error that knows which type, relation or condition it -// concerns. Callers with nothing to add beyond the symbol use addError instead. +// addScopedError resolves where a finding points and records it. The code decides its +// severity and the sentinel it wraps; the raise site's scope decides which part of the +// model it names, and both the category and the metadata are read back off that. func (c *ErrorCollector) addScopedError(message string, errorType ValidationErrorType, symbol string, lineIndex *int, meta *Meta, customResolver ErrorCustomResolver, errorScope scope) { - var line *Range - var column *Range - - // Calculate line and column positions if lineIndex is provided - if lineIndex != nil && *lineIndex >= 0 && *lineIndex < len(c.lines) { - line = &Range{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) - } + line, column := c.position(symbol, lineIndex, customResolver) - if symbolPos >= 0 { - column = &Range{ - Start: symbolPos, - End: symbolPos + len(symbol), - } - } + part := errorScope.part + if part == nil { + // A raise site that named nothing. Treat it as being about the model as a + // whole, which is what a code with no scope means. + part = &fgaerrors.ErrModel{} } entry := lookupErrorInfo(errorType) - - category := entry.Category - if errorScope.category != fgaerrors.ModelErrorKindUnspecified { - category = errorScope.category - } - - // The cause carries the scope and the metadata is derived from it, so the JSON - // and the errors.As payload cannot disagree. offendingType is metadata only, so - // it comes straight off the scope. - cause := newScopedCause(category, errorScope, entry.Cause) - objectType, relation, condition := causeScope(cause) + partScope := part.Scope() metadata := &ErrorMetadata{ Symbol: symbol, ErrorType: errorType, OffendingType: errorScope.offendingType, - Type: objectType, - Relation: relation, - Condition: condition, + Type: partScope.ObjectType, + Relation: partScope.Relation, + Condition: partScope.Condition, } if meta != nil { @@ -190,11 +167,15 @@ func (c *ErrorCollector) addScopedError(message string, errorType ValidationErro validationErr := &ValidationError{ Message: message, Severity: entry.Severity, - Category: category, + Category: part.Kind(), Line: line, Column: column, Metadata: metadata, - Cause: cause, + + // A code missing from the table has no sentinel, so there is nothing for + // errors.Is to match and this is nil. The category and metadata above still + // report what the raise site named. + Cause: fgaerrors.WithSentinel(part, entry.Cause), } if meta != nil { @@ -204,68 +185,33 @@ func (c *ErrorCollector) addScopedError(message string, errorType ValidationErro c.errors = append(c.errors, validationErr) } -// newScopedCause wraps sentinel in the error type matching category, carrying -// whichever scope fields that type declares. Returns nil when there is no sentinel, -// which is the case for codes absent from the table. -func newScopedCause(category fgaerrors.ModelErrorKind, errorScope scope, sentinel error) error { - if sentinel == nil { - return nil +// position resolves the line and column a finding points at, both nil when the raise +// site gave no line or the line is outside the source. +func (c *ErrorCollector) position(symbol string, lineIndex *int, + customResolver ErrorCustomResolver) (line, column *Range) { + if lineIndex == nil || *lineIndex < 0 || *lineIndex >= len(c.lines) { + return nil, nil } - switch category { - case fgaerrors.ErrorKindObjectType: - return &fgaerrors.ErrObjectType{ - ObjectType: errorScope.objectType, - Cause: sentinel, - } - case fgaerrors.ErrorKindRelation: - return &fgaerrors.ErrRelation{ - ObjectType: errorScope.objectType, - Relation: errorScope.relation, - Cause: sentinel, - } - case fgaerrors.ErrorKindRelationCondition: - return &fgaerrors.ErrRelationCondition{ - ObjectType: errorScope.objectType, - Relation: errorScope.relation, - Condition: errorScope.condition, - Cause: sentinel, - } - case fgaerrors.ErrorKindCondition: - return &fgaerrors.ErrCondition{ - Condition: errorScope.condition, - Cause: sentinel, - } - default: - // ErrorKindInvalidModel, and anything unrecognised: a finding no part of the - // model owns. - return &fgaerrors.ErrModel{Cause: sentinel} + line = &Range{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) } -} -// causeScope reads the scope off whichever error type cause is, so the metadata -// carries exactly the fields that type declares. A cause with no scope to report, -// *ErrModel or nil, yields three empty strings, which omitempty drops. -func causeScope(cause error) (objectType, relation, condition string) { - var ( - objectTypeErr *fgaerrors.ErrObjectType - relationErr *fgaerrors.ErrRelation - relationConditionErr *fgaerrors.ErrRelationCondition - conditionErr *fgaerrors.ErrCondition - ) - - switch { - case errors.As(cause, &objectTypeErr): - return objectTypeErr.ObjectType, "", "" - case errors.As(cause, &relationErr): - return relationErr.ObjectType, relationErr.Relation, "" - case errors.As(cause, &relationConditionErr): - return relationConditionErr.ObjectType, relationConditionErr.Relation, relationConditionErr.Condition - case errors.As(cause, &conditionErr): - return "", "", conditionErr.Condition - default: - return "", "", "" + if symbolPos >= 0 { + column = &Range{ + Start: symbolPos, + End: symbolPos + len(symbol), + } } + + return line, column } // RaiseInvalidName raises an invalid name error. @@ -273,11 +219,11 @@ func (c *ErrorCollector) RaiseInvalidName(symbol, clause string, typeName *strin var message string // A nil typeName means the offending name is a type rather than a relation on // one, which changes both the message and the scope of the finding. - errorScope := scope{objectType: symbol, category: fgaerrors.ErrorKindObjectType} + errorScope := scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}} if typeName != nil { message = fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", symbol, *typeName, clause) - errorScope = scope{objectType: *typeName, relation: symbol} + errorScope = scope{part: &fgaerrors.ErrRelation{ObjectType: *typeName, Relation: symbol}} } else { message = fmt.Sprintf("type '%s' does not match naming rule: '%s'.", symbol, clause) } @@ -290,8 +236,7 @@ func (c *ErrorCollector) RaiseInvalidName(symbol, clause string, typeName *strin func (c *ErrorCollector) RaiseInvalidConditionName(symbol, clause string, lineIndex *int, meta *Meta) { message := fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", symbol, clause) c.addScopedError(message, InvalidName, symbol, lineIndex, meta, nil, scope{ - condition: symbol, - category: fgaerrors.ErrorKindCondition, + part: &fgaerrors.ErrCondition{Condition: symbol}, }) } @@ -299,7 +244,7 @@ func (c *ErrorCollector) RaiseInvalidConditionName(symbol, clause string, lineIn func (c *ErrorCollector) RaiseReservedTypeName(symbol string, lineIndex *int, meta *Meta) { message := "a type cannot be named 'self' or 'this'." c.addScopedError(message, ReservedTypeKeywords, symbol, lineIndex, meta, nil, scope{ - objectType: symbol, + part: &fgaerrors.ErrObjectType{ObjectType: symbol}, }) } @@ -307,8 +252,7 @@ func (c *ErrorCollector) RaiseReservedTypeName(symbol string, lineIndex *int, me func (c *ErrorCollector) RaiseReservedRelationName(symbol, typeName string, lineIndex *int, meta *Meta) { message := "a relation cannot be named 'self' or 'this'." c.addScopedError(message, ReservedRelationKeywords, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: symbol, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}, }) } @@ -326,8 +270,7 @@ func (c *ErrorCollector) RaiseTupleUsersetRequiresDirect(symbol, typeName, relat } c.addScopedError(message, TuplesetNotDirect, symbol, lineIndex, meta, customResolver, scope{ - objectType: typeName, - relation: relation, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}, }) } @@ -337,8 +280,7 @@ func (c *ErrorCollector) RaiseDuplicateTypeName(symbol string, meta *Meta, lineI // A duplicate type is about the type, not a relation on it, so this overrides // DuplicatedError's relation-scoped default. c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - objectType: symbol, - category: fgaerrors.ErrorKindObjectType, + part: &fgaerrors.ErrObjectType{ObjectType: symbol}, }) } @@ -346,8 +288,7 @@ func (c *ErrorCollector) RaiseDuplicateTypeName(symbol string, meta *Meta, lineI 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.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, }) } @@ -357,7 +298,7 @@ func (c *ErrorCollector) RaiseUndefinedType(typeName, relationName, parentTypeNa // The undefined type is the subject; parentTypeName is only where it was // referenced from, so the scope names the type that does not exist. c.addScopedError(message, UndefinedType, typeName, lineIndex, meta, nil, scope{ - objectType: typeName, + part: &fgaerrors.ErrObjectType{ObjectType: typeName}, }) } @@ -365,8 +306,7 @@ func (c *ErrorCollector) RaiseUndefinedType(typeName, relationName, parentTypeNa 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.addScopedError(message, UndefinedRelation, relationName, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, }) } @@ -375,8 +315,7 @@ func (c *ErrorCollector) RaiseDuplicateType(symbol, relationName, typeName strin message := fmt.Sprintf("the partial relation definition `%s` is a duplicate in the relation `%s`.", symbol, relationName) c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, }) } @@ -384,7 +323,7 @@ func (c *ErrorCollector) RaiseDuplicateType(symbol, relationName, typeName strin func (c *ErrorCollector) RaiseDuplicateRelationshipDefinition(symbol string, meta *Meta, lineIndex *int) { message := fmt.Sprintf("the relation '%s' is defined more than once.", symbol) c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - relation: symbol, + part: &fgaerrors.ErrRelation{Relation: symbol}, }) } @@ -392,8 +331,7 @@ func (c *ErrorCollector) RaiseDuplicateRelationshipDefinition(symbol string, met 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.addScopedError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: symbol, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}, }) } @@ -401,8 +339,7 @@ func (c *ErrorCollector) RaiseNoEntryPointLoop(symbol, typeName string, meta *Me 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.addScopedError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: symbol, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}, }) } @@ -412,8 +349,7 @@ func (c *ErrorCollector) RaiseInvalidRelationOnTupleset(symbol, typeName, typeDe 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.addScopedError(message, InvalidRelationOnTupleset, symbol, lineIndex, meta, nil, scope{ - objectType: typeDef, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeDef, Relation: relationName}, }) } @@ -422,8 +358,7 @@ func (c *ErrorCollector) RaiseInvalidTypeRelation(symbol, typeName, relationName offendingType string, lineIndex *int, meta *Meta) { message := fmt.Sprintf("`%s` is not a valid relation for `%s`.", offendingRelation, typeName) c.addScopedError(message, InvalidRelationType, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, offendingType: offendingType, }) } @@ -445,7 +380,7 @@ func (c *ErrorCollector) RaiseInvalidType(symbol, typeName, relation string, met return colon + 1 + idx } c.addScopedError(message, InvalidType, symbol, lineIndex, meta, resolver, scope{ - objectType: symbol, + part: &fgaerrors.ErrObjectType{ObjectType: symbol}, }) } @@ -453,7 +388,7 @@ func (c *ErrorCollector) RaiseInvalidType(symbol, typeName, relation string, met func (c *ErrorCollector) RaiseAssignableRelationMustHaveTypes(symbol string, lineIndex *int) { message := fmt.Sprintf("the assignable relation '%s' must have at least one assignable type.", symbol) c.addScopedError(message, AssignableRelationsMustHaveType, symbol, lineIndex, nil, nil, scope{ - relation: symbol, + part: &fgaerrors.ErrRelation{Relation: symbol}, }) } @@ -462,8 +397,7 @@ func (c *ErrorCollector) RaiseAssignableTypeWildcardRelation(symbol, typeName, r 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.addScopedError(message, TypeRestrictionCannotHaveWildcardAndRelation, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relation, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}, }) } @@ -474,8 +408,7 @@ func (c *ErrorCollector) RaiseInvalidRelationError(symbol, typeName, relation st lineIndex *int, meta *Meta) { message := fmt.Sprintf("the relation `%s` does not exist.", symbol) c.addScopedError(message, MissingDefinition, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relation, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}, }) } @@ -484,27 +417,27 @@ func (c *ErrorCollector) RaiseInvalidRelationError(symbol, typeName, relation st // 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) + c.addError(message, InvalidSchema, symbol, lineIndex) } // 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) + c.addError(message, SchemaVersionUnsupported, symbol, lineIndex) } // 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) + c.addError(message, SchemaVersionRequired, symbol, lineIndex) } // 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.addScopedError(message, DuplicatedError, symbol, lineIndex, nil, nil, scope{ - relation: symbol, + part: &fgaerrors.ErrRelation{Relation: symbol}, }) } @@ -515,9 +448,7 @@ func (c *ErrorCollector) RaiseInvalidConditionNameInParameter(symbol, typeName, // Scoped to the relation the condition is applied to, not the condition's own // definition: the condition does not exist to have a definition. c.addScopedError(message, ConditionNotDefined, symbol, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, - condition: conditionName, + part: &fgaerrors.ErrRelationCondition{ObjectType: typeName, Relation: relationName, Condition: conditionName}, }) } @@ -525,7 +456,7 @@ func (c *ErrorCollector) RaiseInvalidConditionNameInParameter(symbol, typeName, func (c *ErrorCollector) RaiseUnusedCondition(symbol string, meta *Meta, lineIndex *int) { message := fmt.Sprintf("`%s` condition is not used in the model.", symbol) c.addScopedError(message, ConditionNotUsed, symbol, lineIndex, meta, nil, scope{ - condition: symbol, + part: &fgaerrors.ErrCondition{Condition: symbol}, }) } @@ -534,7 +465,7 @@ func (c *ErrorCollector) RaiseUnusedCondition(symbol string, meta *Meta, lineInd func (c *ErrorCollector) RaiseDifferentNestedConditionName(condition, nestedConditionName string) { message := fmt.Sprintf("condition key is `%s` but nested name property is %s", condition, nestedConditionName) c.addScopedError(message, DifferentNestedConditionName, nestedConditionName, nil, nil, nil, scope{ - condition: condition, + part: &fgaerrors.ErrCondition{Condition: condition}, }) } @@ -545,7 +476,7 @@ func (c *ErrorCollector) RaiseMultipleModulesInSingleFile(file string, modules [ moduleList := strings.Join(modules, ", ") message := fmt.Sprintf("file %s would contain multiple module definitions (%s) when transforming to DSL. "+ "Only one module can be defined per file.", file, moduleList) - c.addError(message, MultipleModulesInFile, file, nil, nil, nil) + c.addError(message, MultipleModulesInFile, file, nil) } // Complex operation validation error methods @@ -554,8 +485,7 @@ func (c *ErrorCollector) RaiseMultipleModulesInSingleFile(file string, modules [ 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.addScopedError(message, DuplicatedError, operation, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, }) } @@ -564,8 +494,7 @@ func (c *ErrorCollector) RaiseImpossibleIntersection(relationName, typeName stri typeList := strings.Join(conflictingTypes, ", ") message := fmt.Sprintf("Impossible intersection in relation '%s' of type '%s': conflicting types [%s]", relationName, typeName, typeList) c.addScopedError(message, InvalidRelationType, relationName, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, }) } @@ -573,7 +502,6 @@ func (c *ErrorCollector) RaiseImpossibleIntersection(relationName, typeName stri 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.addScopedError(message, RelationNoEntrypoint, relationName, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, }) } diff --git a/pkg/go/validation/error_info.go b/pkg/go/validation/error_info.go index a27bb25d..5b9aba15 100644 --- a/pkg/go/validation/error_info.go +++ b/pkg/go/validation/error_info.go @@ -4,11 +4,10 @@ import ( fgaerrors "github.com/openfga/language/pkg/go/errors" ) -// errorInfo is what a code implies beyond its message: its severity, the part of -// the model it belongs to, and the sentinel a caller matches with errors.Is. +// errorInfo is what a code implies beyond its message: its severity and the +// sentinel a caller matches with errors.Is. type errorInfo struct { Severity fgaerrors.Severity - Category fgaerrors.ModelErrorKind Cause error // Critical marks a finding that invalidates the model as a whole rather than @@ -17,55 +16,51 @@ type errorInfo struct { Critical bool } -// errorInfoByType maps every code the validator emits to its severity, category, -// cause and criticality. It is the only place those are decided, so a code cannot -// mean one thing in the collector and another in a report. +// errorInfoByType maps every code the validator emits to its severity, cause and +// criticality. It is the only place those are decided, so a code cannot mean one +// thing in the collector and another in a report. // -// Every emitted code must appear here; TestErrorInfoCoversEveryEmittedErrorType -// enforces it, and declared-but-unemitted codes go in unemittedErrorTypes instead. +// Which part of the model a finding is about is not here, because it does not follow +// from the code. DuplicatedError covers a duplicate type and a duplicate type +// restriction; InvalidName covers a type, a relation and a condition. The raise site +// states it by building the cause it passes in scope. // -// Category is a default. DuplicatedError covers both a duplicate type and a -// duplicate type restriction, so a raise site overrides it through scope.category. +// Every emitted code must appear here; TestErrorInfoCoversEveryEmittedErrorType +// enforces it, and declared-but-unemitted codes go in unemittedErrorTypes in +// error_info_test.go instead. var errorInfoByType = map[ValidationErrorType]errorInfo{ // Schema. InvalidSchema: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindInvalidModel, Cause: fgaerrors.ErrInvalidSchemaVersion, Critical: true, }, SchemaVersionUnsupported: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindInvalidModel, Cause: fgaerrors.ErrSchemaVersionUnsupported, }, SchemaVersionRequired: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindInvalidModel, Cause: fgaerrors.ErrSchemaVersionRequired, }, // Naming. InvalidName: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrInvalidName, }, ReservedTypeKeywords: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindObjectType, Cause: fgaerrors.ErrReservedKeywords, }, ReservedRelationKeywords: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrReservedKeywords, }, // Duplicates. DuplicatedError: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrDuplicateDefinition, Critical: true, }, @@ -73,56 +68,47 @@ var errorInfoByType = map[ValidationErrorType]errorInfo{ // Undefined references. UndefinedType: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindObjectType, Cause: fgaerrors.ErrObjectTypeUndefined, Critical: true, }, UndefinedRelation: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrRelationUndefined, Critical: true, }, MissingDefinition: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrRelationUndefined, }, // Types and type restrictions. InvalidType: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindObjectType, Cause: fgaerrors.ErrInvalidType, }, InvalidRelationType: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrInvalidRelationType, Critical: true, }, AssignableRelationsMustHaveType: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrDirectlyAssignableRelation, }, // Tuplesets. InvalidRelationOnTupleset: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrInvalidRelationOnTupleset, }, TuplesetNotDirect: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrInvalidRelationOnTuplesetNotDirect, }, // Entrypoints. RelationNoEntrypoint: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrNoEntrypoints, Critical: true, }, @@ -130,97 +116,35 @@ var errorInfoByType = map[ValidationErrorType]errorInfo{ // Wildcards. InvalidWildcardError: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrInvalidWildcard, }, TypeRestrictionCannotHaveWildcardAndRelation: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, Cause: fgaerrors.ErrInvalidWildcard, }, // Conditions. ConditionNotDefined: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelationCondition, Cause: fgaerrors.ErrConditionUndefined, }, ConditionNotUsed: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindCondition, Cause: fgaerrors.ErrConditionUnReferenced, }, DifferentNestedConditionName: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindCondition, Cause: fgaerrors.ErrConditionNameMismatch, }, // Modules. MultipleModulesInFile: { Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindInvalidModel, Cause: fgaerrors.ErrMultipleModulesInFile, Critical: true, }, } -// unemittedErrorTypes are declared ValidationErrorType values that no validation -// produces, established by inspecting every errorType argument reaching addError. -// -// They are kept rather than deleted because each has a published documentation -// page, and because SelfError and InvalidSyntax are equally unemitted in -// pkg/js/errors.ts. A cycle with no entrypoint surfaces as RelationNoEntrypoint, -// leaving CyclicError and CyclicRelation nothing to report. InvalidSchemaVersion is -// unreachable because RaiseInvalidSchemaVersion emits InvalidSchema, which is what -// the shared corpus expects. -// -// None get an errorInfoByType entry, so lookupErrorInfo treats them as blocking -// with no cause. Anything that starts emitting one must add it to the table in the -// same change. -var unemittedErrorTypes = map[ValidationErrorType]struct{}{ - SelfError: {}, - InvalidSyntax: {}, - CyclicError: {}, - CyclicRelation: {}, - InvalidSchemaVersion: {}, -} - -// allErrorTypes lists every declared ValidationErrorType. A Go const block of a -// string type cannot be enumerated at runtime, so exhaustiveness checks need it -// written out. -// -// Keep in sync with the const block in errors.go. -var allErrorTypes = []ValidationErrorType{ - SchemaVersionRequired, - SchemaVersionUnsupported, - ReservedTypeKeywords, - ReservedRelationKeywords, - SelfError, - InvalidName, - MissingDefinition, - InvalidRelationType, - InvalidRelationOnTupleset, - InvalidType, - RelationNoEntrypoint, - TuplesetNotDirect, - DuplicatedError, - UndefinedType, - UndefinedRelation, - CyclicError, - InvalidWildcardError, - AssignableRelationsMustHaveType, - InvalidSchema, - InvalidSyntax, - TypeRestrictionCannotHaveWildcardAndRelation, - ConditionNotDefined, - ConditionNotUsed, - DifferentNestedConditionName, - MultipleModulesInFile, - CyclicRelation, - InvalidSchemaVersion, -} - // isCriticalErrorType reports whether a code invalidates the model as a whole. // Criticality is a field on the errorInfo entry, so a code cannot be critical and // non-blocking at once. Unknown codes are blocking but not critical. @@ -238,6 +162,5 @@ func lookupErrorInfo(errorType ValidationErrorType) errorInfo { } return errorInfo{ Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindInvalidModel, } } diff --git a/pkg/go/validation/error_info_integration_test.go b/pkg/go/validation/error_info_integration_test.go index 18cea593..7553fcdf 100644 --- a/pkg/go/validation/error_info_integration_test.go +++ b/pkg/go/validation/error_info_integration_test.go @@ -158,6 +158,18 @@ condition inRegion(x: string) { } } +// findingScope reads the scope off a finding the way a consumer does: errors.As from +// the outer error, rather than off the Cause field. A finding carrying no scoped +// cause yields an empty scope. +func findingScope(finding *ValidationError) fgaerrors.ModelErrorScope { + var modelErr fgaerrors.ModelError + if !errors.As(error(finding), &modelErr) { + return fgaerrors.ModelErrorScope{} + } + + return modelErr.Scope() +} + // TestMetadataIsDerivedFromCause checks the serialised metadata and the errors.As // payload describe the same scope, so the two cannot drift. func TestMetadataIsDerivedFromCause(t *testing.T) { @@ -180,13 +192,13 @@ type document continue } - objectType, relation, condition := causeScope(error(validationErr)) + causeScope := findingScope(validationErr) require.NotNil(t, validationErr.Metadata) - assert.Equal(t, objectType, validationErr.Metadata.Type, + assert.Equal(t, causeScope.ObjectType, validationErr.Metadata.Type, "metadata type must match the cause it was derived from") - assert.Equal(t, relation, validationErr.Metadata.Relation) - assert.Equal(t, condition, validationErr.Metadata.Condition) + assert.Equal(t, causeScope.Relation, validationErr.Metadata.Relation) + assert.Equal(t, causeScope.Condition, validationErr.Metadata.Condition) checked++ } @@ -263,6 +275,12 @@ type document assert.NotEmptyf(t, validationErr.Severity, "model %d: %q has no severity", index, errorType) + // The category comes off the cause the raise site built, so a finding + // with none has a raise site that named no part of the model. + assert.Truef(t, validationErr.Category.IsValid(), + "model %d: %q has no category, so its raise site named no part of the model", + index, errorType) + if _, classified := errorInfoByType[errorType]; classified { require.Errorf(t, validationErr.Unwrap(), "model %d: %q is in the errorInfoByType but carries no cause", index, errorType) diff --git a/pkg/go/validation/error_info_test.go b/pkg/go/validation/error_info_test.go index 59dd7bcf..a71bc8f1 100644 --- a/pkg/go/validation/error_info_test.go +++ b/pkg/go/validation/error_info_test.go @@ -16,6 +16,65 @@ import ( fgaerrors "github.com/openfga/language/pkg/go/errors" ) +// unemittedErrorTypes are declared ValidationErrorType values that no validation +// produces. The other side is read out of the source by emittedErrorTypes below, and +// TestEveryErrorTypeIsClassified requires every declared code to be in one or the +// other. +// +// They are kept rather than deleted because each has a published documentation +// page, and because SelfError and InvalidSyntax are equally unemitted in +// pkg/js/errors.ts. A cycle with no entrypoint surfaces as RelationNoEntrypoint, +// leaving CyclicError and CyclicRelation nothing to report. InvalidSchemaVersion is +// unreachable because RaiseInvalidSchemaVersion emits InvalidSchema, which is what +// the shared corpus expects. +// +// None get an errorInfoByType entry, so lookupErrorInfo treats them as blocking +// with no cause. Anything that starts emitting one must add it to that table in the +// same change. +var unemittedErrorTypes = map[ValidationErrorType]struct{}{ + SelfError: {}, + InvalidSyntax: {}, + CyclicError: {}, + CyclicRelation: {}, + InvalidSchemaVersion: {}, +} + +// allErrorTypes lists every declared ValidationErrorType. A Go const block of a +// string type cannot be enumerated at runtime, so exhaustiveness checks need it +// written out. +// +// Keep in sync with the const block in errors.go. TestAllErrorTypesIsComplete reads +// that block and fails if the two disagree. +var allErrorTypes = []ValidationErrorType{ + SchemaVersionRequired, + SchemaVersionUnsupported, + ReservedTypeKeywords, + ReservedRelationKeywords, + SelfError, + InvalidName, + MissingDefinition, + InvalidRelationType, + InvalidRelationOnTupleset, + InvalidType, + RelationNoEntrypoint, + TuplesetNotDirect, + DuplicatedError, + UndefinedType, + UndefinedRelation, + CyclicError, + InvalidWildcardError, + AssignableRelationsMustHaveType, + InvalidSchema, + InvalidSyntax, + TypeRestrictionCannotHaveWildcardAndRelation, + ConditionNotDefined, + ConditionNotUsed, + DifferentNestedConditionName, + MultipleModulesInFile, + CyclicRelation, + InvalidSchemaVersion, +} + // emittedErrorTypes parses this package's non-test sources and returns the name of // every ValidationErrorType passed as the errorType argument of an addError call. It // reads the source rather than a hand-written list, which would go stale in the same @@ -166,7 +225,9 @@ func TestAllErrorTypesIsComplete(t *testing.T) { } // TestErrorInfoEntriesAreWellFormed checks each entry says something usable: a -// severity that exists, a category that serialises, and a non-nil cause. +// severity that exists and a non-nil cause. Which part of the model a code is about +// is not in the table, so it is checked on the findings themselves, in +// TestEverySemanticFindingCarriesErrorInfo. func TestErrorInfoEntriesAreWellFormed(t *testing.T) { t.Parallel() @@ -176,14 +237,6 @@ func TestErrorInfoEntriesAreWellFormed(t *testing.T) { fgaerrors.SeverityAdvisory: {}, } - validCategories := map[fgaerrors.ModelErrorKind]struct{}{ - fgaerrors.ErrorKindObjectType: {}, - fgaerrors.ErrorKindRelation: {}, - fgaerrors.ErrorKindRelationCondition: {}, - fgaerrors.ErrorKindCondition: {}, - fgaerrors.ErrorKindInvalidModel: {}, - } - for errorType, entry := range errorInfoByType { t.Run(string(errorType), func(t *testing.T) { t.Parallel() @@ -191,10 +244,6 @@ func TestErrorInfoEntriesAreWellFormed(t *testing.T) { _, ok := validSeverities[entry.Severity] assert.Truef(t, ok, "severity %q is not one of error/warning/advisory", entry.Severity) - // A typo here would silently mint a new wire name. - _, ok = validCategories[entry.Category] - assert.Truef(t, ok, "category %q is not a declared ModelErrorKind", entry.Category) - assert.Error(t, entry.Cause, "no cause: errors.Is has nothing to match against") }) } diff --git a/pkg/go/validation/errors.go b/pkg/go/validation/errors.go index 08387792..84361673 100644 --- a/pkg/go/validation/errors.go +++ b/pkg/go/validation/errors.go @@ -85,12 +85,14 @@ type ValidationError struct { Metadata *ErrorMetadata `json:"metadata,omitempty"` // Cause is the scoped error this finding wraps, and what Unwrap returns: - // errors.Is identifies the condition, errors.As the part of the model. + // errors.Is identifies the condition, errors.As or Kind the part of the model. + // It is nil for a finding whose code has no sentinel, and for one built directly + // rather than through the collector. // // It stays off the wire because an error field has no concrete type to decode // into, which would leave ValidationError unable to round-trip. The message, // severity and metadata carry the same information in JSON. - Cause error `json:"-"` + Cause fgaerrors.ModelError `json:"-"` } // Error implements the error interface. diff --git a/pkg/go/validation/severity_fixtures_test.go b/pkg/go/validation/severity_fixtures_test.go index 05283ff3..4241559c 100644 --- a/pkg/go/validation/severity_fixtures_test.go +++ b/pkg/go/validation/severity_fixtures_test.go @@ -131,10 +131,10 @@ func findSeverityFixtureMatch( continue } - objectType, relation, condition := causeScope(error(finding)) - if objectType == want.Scope.ObjectType && - relation == want.Scope.Relation && - condition == want.Scope.Condition { + causeScope := findingScope(finding) + if causeScope.ObjectType == want.Scope.ObjectType && + causeScope.Relation == want.Scope.Relation && + causeScope.Condition == want.Scope.Condition { return finding } } diff --git a/pkg/go/validation/severity_predicates_test.go b/pkg/go/validation/severity_predicates_test.go index 0369c465..9a3f2fa4 100644 --- a/pkg/go/validation/severity_predicates_test.go +++ b/pkg/go/validation/severity_predicates_test.go @@ -166,6 +166,29 @@ func TestUnwrapReachesEveryFinding(t *testing.T) { assert.Equal(t, "viewer", scoped.Relation) } +// TestUnwrapOfAnUnsetCauseIsNil checks a finding with no cause unwraps to a nil error +// rather than to a non-nil error holding nothing. +// +// Cause is an interface, so Unwrap converts one interface value to another. An unset +// Cause is a nil interface and converts to a nil error; a scope wrapping a nil +// sentinel would not, which is why WithSentinel yields nothing rather than building +// one. +func TestUnwrapOfAnUnsetCauseIsNil(t *testing.T) { + t.Parallel() + + unset := finding(fgaerrors.SeverityError, "no cause") + + require.Nil(t, unset.Cause) + require.NoError(t, unset.Unwrap()) + + // The path a code missing from errorInfoByType takes: no sentinel to wrap, so the + // collector stores nothing rather than a scope wrapping nil. + assert.Nil(t, fgaerrors.WithSentinel(&fgaerrors.ErrRelation{Relation: "viewer"}, nil)) + + collection := NewValidationErrors([]*ValidationError{unset}) + assert.NotErrorIs(t, collection, fgaerrors.ErrNoEntrypoints) +} + // TestUnwrapSkipsNilFindings checks a directly-constructed collection holding a nil // entry does not panic: a nil *ValidationError handed to errors.Is as a non-nil // error would dereference nil on Unwrap. diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go index 238bff71..6eed62c7 100644 --- a/pkg/go/validation/wildcard_validation.go +++ b/pkg/go/validation/wildcard_validation.go @@ -6,6 +6,8 @@ import ( "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" + + fgaerrors "github.com/openfga/language/pkg/go/errors" ) // ValidateWildcardUsage validates wildcard relation usage rules. @@ -158,8 +160,7 @@ func (c *ErrorCollector) RaiseInvalidWildcardUsage(typeName, relationName, paren // The wildcard is written in a relation of parentTypeName; typeName is the // restriction it appears in, which the symbol already records. c.addScopedError(message, InvalidWildcardError, typeName, lineIndex, meta, nil, scope{ - objectType: parentTypeName, - relation: relationName, + part: &fgaerrors.ErrRelation{ObjectType: parentTypeName, Relation: relationName}, }) } @@ -167,7 +168,6 @@ func (c *ErrorCollector) RaiseTuplesetNotDirect(tuplesetRelation, typeName, pare message := fmt.Sprintf("Tupleset relation '%s' on type '%s' must allow direct assignment (used in relation '%s')", tuplesetRelation, typeName, parentRelation) c.addScopedError(message, TuplesetNotDirect, tuplesetRelation, lineIndex, meta, nil, scope{ - objectType: typeName, - relation: tuplesetRelation, + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: tuplesetRelation}, }) } From cf9a9c98e6b139b4b52b091c21b2b7918593f561 Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Tue, 25 Aug 2026 10:11:01 +0530 Subject: [PATCH 3/8] refactor: inline wireName into String, make IsValid a switch --- pkg/go/errors/model_error_kind.go | 75 +++++++++++++------------------ pkg/go/errors/severity.go | 63 ++++++++++---------------- 2 files changed, 56 insertions(+), 82 deletions(-) diff --git a/pkg/go/errors/model_error_kind.go b/pkg/go/errors/model_error_kind.go index 9b780729..7765a54b 100644 --- a/pkg/go/errors/model_error_kind.go +++ b/pkg/go/errors/model_error_kind.go @@ -35,31 +35,7 @@ const ( ErrorKindInvalidModel ) -// wireName returns the name a category serialises as, and an empty string for a -// value with no name. It is the only place the mapping lives, so String, IsValid -// and MarshalText cannot disagree about which values have one. -func (m ModelErrorKind) wireName() string { - switch m { - case ErrorKindObjectType: - return "object-type" - case ErrorKindRelation: - return "relation" - case ErrorKindRelationCondition: - return "relation-condition" - case ErrorKindCondition: - return "condition" - case ErrorKindInvalidModel: - return "invalid-model" - default: - // ModelErrorKindUnspecified lands here too: the zero value has no name by - // design, so it marshals as a failure rather than as a category. - return "" - } -} - -// modelErrorKindFromName is the reverse of wireName. The two are written out -// separately rather than derived from one another, so TestModelErrorKindWireNames -// round trips every declared category to keep them in step. +// modelErrorKindFromName maps a wire name back to its category. func modelErrorKindFromName(name string) (ModelErrorKind, bool) { switch name { case "object-type": @@ -77,36 +53,49 @@ func modelErrorKindFromName(name string) (ModelErrorKind, bool) { } } -// String returns the wire name, or a diagnostic form for a value with none. +// String returns the wire name of a declared category, an empty string for the +// zero value, and a diagnostic form for any other number. func (m ModelErrorKind) String() string { - if name := m.wireName(); name != "" { - return name - } - - if m == ModelErrorKindUnspecified { + switch m { + case ErrorKindObjectType: + return "object-type" + case ErrorKindRelation: + return "relation" + case ErrorKindRelationCondition: + return "relation-condition" + case ErrorKindCondition: + return "condition" + case ErrorKindInvalidModel: + return "invalid-model" + case ModelErrorKindUnspecified: return "" + default: + return fmt.Sprintf("ModelErrorKind(%d)", int(m)) } - - return fmt.Sprintf("ModelErrorKind(%d)", int(m)) } -// IsValid reports whether m is a declared category with a wire name. +// IsValid reports whether m is a declared category. func (m ModelErrorKind) IsValid() bool { - return m.wireName() != "" + switch m { + case ErrorKindObjectType, + ErrorKindRelation, + ErrorKindRelationCondition, + ErrorKindCondition, + ErrorKindInvalidModel: + return true + default: + return false + } } -// MarshalText emits the wire name, so the JSON carries "object-type". -// -// It does not go through String, because String has a diagnostic form for an -// undeclared number and this has to have none: a category that cannot be named -// must fail to marshal rather than ship as ModelErrorKind(99). +// MarshalText emits the wire name, so the JSON carries "object-type". An +// undeclared value must fail to marshal rather than ship String's diagnostic form. func (m ModelErrorKind) MarshalText() ([]byte, error) { - name := m.wireName() - if name == "" { + if !m.IsValid() { return nil, fmt.Errorf("%w: %d", ErrUnknownModelErrorKind, int(m)) } - return []byte(name), nil + return []byte(m.String()), nil } // UnmarshalText resolves a wire name back to its category, rejecting any name diff --git a/pkg/go/errors/severity.go b/pkg/go/errors/severity.go index 93b27485..cc67993c 100644 --- a/pkg/go/errors/severity.go +++ b/pkg/go/errors/severity.go @@ -28,27 +28,7 @@ const ( SeverityAdvisory ) -// wireName returns the name a severity serialises as, and an empty string for a -// value with no name. It is the only place the mapping lives, so String, IsValid -// and MarshalText cannot disagree about which values have one. -func (s Severity) wireName() string { - switch s { - case SeverityError: - return "error" - case SeverityWarning: - return "warning" - case SeverityAdvisory: - return "advisory" - default: - // SeverityUnspecified lands here too: the zero value has no name by design, - // so it marshals as a failure rather than as a severity. - return "" - } -} - -// severityFromName is the reverse of wireName. The two are written out separately -// rather than derived from one another, so TestSeverityWireNames round trips every -// declared severity to keep them in step. +// severityFromName maps a wire name back to its severity. func severityFromName(name string) (Severity, bool) { switch name { case "error": @@ -62,22 +42,31 @@ func severityFromName(name string) (Severity, bool) { } } -// String returns the wire name, or a diagnostic form for a value with none. +// String returns the wire name of a declared severity, an empty string for the +// zero value, and a diagnostic form for any other number. func (s Severity) String() string { - if name := s.wireName(); name != "" { - return name - } - - if s == SeverityUnspecified { + switch s { + case SeverityError: + return "error" + case SeverityWarning: + return "warning" + case SeverityAdvisory: + return "advisory" + case SeverityUnspecified: return "" + default: + return fmt.Sprintf("Severity(%d)", int(s)) } - - return fmt.Sprintf("Severity(%d)", int(s)) } -// IsValid reports whether s is a declared severity with a wire name. +// IsValid reports whether s is a declared severity. func (s Severity) IsValid() bool { - return s.wireName() != "" + switch s { + case SeverityError, SeverityWarning, SeverityAdvisory: + return true + default: + return false + } } // Blocks reports whether a finding of this severity makes validation fail. @@ -89,18 +78,14 @@ func (s Severity) Blocks() bool { return s != SeverityWarning && s != SeverityAdvisory } -// MarshalText emits the wire name, so the JSON carries "warning". -// -// It does not go through String, because String has a diagnostic form for an -// undeclared number and this has to have none: a severity that cannot be named must -// fail to marshal rather than ship as Severity(99). +// MarshalText emits the wire name, so the JSON carries "warning". An undeclared +// value must fail to marshal rather than ship String's diagnostic form. func (s Severity) MarshalText() ([]byte, error) { - name := s.wireName() - if name == "" { + if !s.IsValid() { return nil, fmt.Errorf("%w: %d", ErrUnknownSeverity, int(s)) } - return []byte(name), nil + return []byte(s.String()), nil } // UnmarshalText resolves a wire name back to its severity, rejecting any name From edba5c034d4eb4f6fb86ee8e8cbb12166dbacb5c Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 28 Aug 2026 22:16:15 +0530 Subject: [PATCH 4/8] refactor(pkg/go): raise validation errors at the point they are found Each validation error is now built by a constructor at the site that detects it and added to ValidationErrors, which is the only sink. The ErrorCollector type and its Raise* methods are gone; position resolution lives in error_construction.go and the constructors in error_builders.go. Output is unchanged: the shared corpora under tests/data still match on message, symbol, error type and position. --- .../complex_operation_validation.go | 74 +-- pkg/go/validation/condition_validation.go | 20 +- .../validation/condition_validation_test.go | 18 +- pkg/go/validation/cycle_detection.go | 10 +- .../validation/cycle_detection_stress_test.go | 10 +- pkg/go/validation/cycle_detection_test.go | 6 +- pkg/go/validation/duplicate_detection.go | 32 +- pkg/go/validation/duplicate_detection_test.go | 14 +- pkg/go/validation/error_builders.go | 293 ++++++++++ pkg/go/validation/error_builders_test.go | 399 ++++++++++++++ pkg/go/validation/error_collector.go | 507 ------------------ pkg/go/validation/error_collector_test.go | 421 --------------- pkg/go/validation/error_construction.go | 150 ++++++ pkg/go/validation/error_info.go | 2 +- .../validation/error_info_integration_test.go | 2 +- pkg/go/validation/error_info_test.go | 40 +- pkg/go/validation/errors.go | 6 +- pkg/go/validation/errors_test.go | 12 +- pkg/go/validation/keywords_test.go | 16 +- pkg/go/validation/multi_file_validation.go | 4 +- .../validation/multi_file_validation_test.go | 8 +- pkg/go/validation/name_validation.go | 30 +- pkg/go/validation/name_validation_test.go | 30 +- pkg/go/validation/schema_validation.go | 18 +- pkg/go/validation/schema_validation_test.go | 10 +- pkg/go/validation/semantic_validation.go | 40 +- pkg/go/validation/semantic_validation_test.go | 10 +- pkg/go/validation/severity_predicates_test.go | 14 +- pkg/go/validation/validation_engine.go | 49 +- pkg/go/validation/wildcard_validation.go | 69 +-- 30 files changed, 1098 insertions(+), 1216 deletions(-) create mode 100644 pkg/go/validation/error_builders.go create mode 100644 pkg/go/validation/error_builders_test.go delete mode 100644 pkg/go/validation/error_collector.go delete mode 100644 pkg/go/validation/error_collector_test.go create mode 100644 pkg/go/validation/error_construction.go diff --git a/pkg/go/validation/complex_operation_validation.go b/pkg/go/validation/complex_operation_validation.go index c4c3bb05..a53caafe 100644 --- a/pkg/go/validation/complex_operation_validation.go +++ b/pkg/go/validation/complex_operation_validation.go @@ -25,14 +25,14 @@ func newComplexOperationValidator(validator *SemanticValidator) *ComplexOperatio } // ValidateComplexOperations validates all complex operations in the model. -func ValidateComplexOperations(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateComplexOperations(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } - validateComplexOperations(collector, NewSemanticValidator(model), lines) + validateComplexOperations(errs, NewSemanticValidator(model), lines) } -func validateComplexOperations(collector *ErrorCollector, validator *SemanticValidator, lines []string) { +func validateComplexOperations(errs *ValidationErrors, validator *SemanticValidator, lines []string) { model := validator.model if model == nil { return @@ -41,64 +41,64 @@ func validateComplexOperations(collector *ErrorCollector, validator *SemanticVal for _, typeDef := range model.GetTypeDefinitions() { relations := typeDef.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relations)) { - opValidator.validateUsersetOperations(collector, typeDef.GetType(), relationName, + opValidator.validateUsersetOperations(errs, typeDef.GetType(), relationName, relations[relationName], lines) } } } -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)) +func (cov *ComplexOperationValidator) validateUsersetOperations(errs *ValidationErrors, typeName, relationName string, userset *openfgav1.Userset, lines []string) { + cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, userset, lines, make(map[string]bool)) } -func (cov *ComplexOperationValidator) validateUsersetOperationsWithVisited(collector *ErrorCollector, typeName, relationName string, userset *openfgav1.Userset, lines []string, visited map[string]bool) { +func (cov *ComplexOperationValidator) validateUsersetOperationsWithVisited(errs *ValidationErrors, 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) + cov.validateUnionOperationWithVisited(errs, typeName, relationName, union, lines, visited) } if intersection := userset.GetIntersection(); intersection != nil { - cov.validateIntersectionOperationWithVisited(collector, typeName, relationName, intersection, lines, visited) + cov.validateIntersectionOperationWithVisited(errs, typeName, relationName, intersection, lines, visited) } if diff := userset.GetDifference(); diff != nil { - cov.validateDifferenceOperationWithVisited(collector, typeName, relationName, diff, lines, visited) + cov.validateDifferenceOperationWithVisited(errs, typeName, relationName, diff, lines, visited) } - cov.validateNestedOperationsWithVisited(collector, typeName, userset, lines, visited) + cov.validateNestedOperationsWithVisited(errs, typeName, userset, lines, visited) } -func (cov *ComplexOperationValidator) validateUnionOperationWithVisited(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string, visited map[string]bool) { +func (cov *ComplexOperationValidator) validateUnionOperationWithVisited(errs *ValidationErrors, 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) + cov.checkRedundantUnionMembers(errs, typeName, relationName, union, lines) for _, child := range union.GetChild() { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, child, lines, visited) + cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, child, lines, visited) } - cov.validateUnionSemantics(collector, typeName, relationName, union, lines) + cov.validateUnionSemantics(errs, typeName, relationName, union, lines) } -func (cov *ComplexOperationValidator) validateIntersectionOperationWithVisited(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string, visited map[string]bool) { +func (cov *ComplexOperationValidator) validateIntersectionOperationWithVisited(errs *ValidationErrors, 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) + cov.checkImpossibleIntersections(errs, typeName, relationName, intersection, lines) for _, child := range intersection.GetChild() { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, child, lines, visited) + cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, child, lines, visited) } - cov.validateIntersectionSemantics(collector, typeName, relationName, intersection, lines) + cov.validateIntersectionSemantics(errs, typeName, relationName, intersection, lines) } -func (cov *ComplexOperationValidator) validateDifferenceOperationWithVisited(collector *ErrorCollector, typeName, relationName string, difference *openfgav1.Difference, lines []string, visited map[string]bool) { +func (cov *ComplexOperationValidator) validateDifferenceOperationWithVisited(errs *ValidationErrors, 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) + cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, difference.GetBase(), lines, visited) + cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, difference.GetSubtract(), lines, visited) + cov.validateDifferenceSemantics(errs, typeName, relationName, difference, lines) } -func (cov *ComplexOperationValidator) checkRedundantUnionMembers(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string) { +func (cov *ComplexOperationValidator) checkRedundantUnionMembers(errs *ValidationErrors, typeName, relationName string, union *openfgav1.Usersets, lines []string) { seenOperations := make(map[string]bool) for _, child := range union.GetChild() { operationKey := cov.getUsersetOperationKey(child) @@ -108,13 +108,13 @@ func (cov *ComplexOperationValidator) checkRedundantUnionMembers(collector *Erro if seenOperations[operationKey] { lineIndex := GetRelationLineNumber(relationName, lines, nil) meta := cov.getTypeMeta(typeName) - collector.RaiseRedundantUnionMember(operationKey, relationName, typeName, meta, lineIndex) + errs.Add(newRedundantUnionMemberError(lines, operationKey, relationName, typeName, meta, lineIndex)) } seenOperations[operationKey] = true } } -func (cov *ComplexOperationValidator) checkImpossibleIntersections(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { +func (cov *ComplexOperationValidator) checkImpossibleIntersections(errs *ValidationErrors, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { typeRestrictions := make([]string, 0) for _, child := range intersection.GetChild() { if child.GetThis() != nil { @@ -131,29 +131,29 @@ func (cov *ComplexOperationValidator) checkImpossibleIntersections(collector *Er if len(uniqueTypes) > 1 { lineIndex := GetRelationLineNumber(relationName, lines, nil) meta := cov.getTypeMeta(typeName) - collector.RaiseImpossibleIntersection(relationName, typeName, typeRestrictions, meta, lineIndex) + errs.Add(newImpossibleIntersectionError(lines, relationName, typeName, typeRestrictions, meta, lineIndex)) } } -func (cov *ComplexOperationValidator) validateUnionSemantics(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string) { - cov.checkSubsumingUnionMembers(collector, typeName, relationName, union, lines) +func (cov *ComplexOperationValidator) validateUnionSemantics(errs *ValidationErrors, typeName, relationName string, union *openfgav1.Usersets, lines []string) { + cov.checkSubsumingUnionMembers(errs, typeName, relationName, union, lines) } -func (cov *ComplexOperationValidator) validateIntersectionSemantics(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { - cov.checkRedundantIntersectionMembers(collector, typeName, relationName, intersection, lines) +func (cov *ComplexOperationValidator) validateIntersectionSemantics(errs *ValidationErrors, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { + cov.checkRedundantIntersectionMembers(errs, typeName, relationName, intersection, lines) } -func (cov *ComplexOperationValidator) validateDifferenceSemantics(collector *ErrorCollector, typeName, relationName string, difference *openfgav1.Difference, lines []string) { +func (cov *ComplexOperationValidator) validateDifferenceSemantics(errs *ValidationErrors, 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) + errs.Add(newEmptyDifferenceError(lines, relationName, typeName, baseKey, meta, lineIndex)) } } -func (cov *ComplexOperationValidator) validateNestedOperationsWithVisited(collector *ErrorCollector, typeName string, userset *openfgav1.Userset, lines []string, visited map[string]bool) { +func (cov *ComplexOperationValidator) validateNestedOperationsWithVisited(errs *ValidationErrors, 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 @@ -162,7 +162,7 @@ func (cov *ComplexOperationValidator) validateNestedOperationsWithVisited(collec } visited[key] = true if targetUserset := cov.validator.GetRelationUserset(typeName, targetRelation); targetUserset != nil { - cov.validateUsersetOperationsWithVisited(collector, typeName, targetRelation, targetUserset, lines, visited) + cov.validateUsersetOperationsWithVisited(errs, typeName, targetRelation, targetUserset, lines, visited) } } } @@ -198,12 +198,12 @@ func (cov *ComplexOperationValidator) getTypeMeta(typeName string) *Meta { return &Meta{} } -func (cov *ComplexOperationValidator) checkSubsumingUnionMembers(_ *ErrorCollector, _, _ string, _ *openfgav1.Usersets, _ []string) { +func (cov *ComplexOperationValidator) checkSubsumingUnionMembers(_ *ValidationErrors, _, _ 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. } -func (cov *ComplexOperationValidator) checkRedundantIntersectionMembers(_ *ErrorCollector, _, _ string, _ *openfgav1.Usersets, _ []string) { +func (cov *ComplexOperationValidator) checkRedundantIntersectionMembers(_ *ValidationErrors, _, _ string, _ *openfgav1.Usersets, _ []string) { // Would check for intersection members that don't restrict the result, e.g. // intersecting with `this`, which adds no restriction. } diff --git a/pkg/go/validation/condition_validation.go b/pkg/go/validation/condition_validation.go index 1ea30566..dabe3666 100644 --- a/pkg/go/validation/condition_validation.go +++ b/pkg/go/validation/condition_validation.go @@ -74,14 +74,14 @@ func (cv *ConditionValidator) scanRelationMetadataForConditions(typeName, relati } // ValidateUnusedConditions detects and reports unused condition definitions. -func ValidateUnusedConditions(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateUnusedConditions(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } - validateUnusedConditions(collector, NewConditionValidator(model), lines) + validateUnusedConditions(errs, NewConditionValidator(model), lines) } -func validateUnusedConditions(collector *ErrorCollector, validator *ConditionValidator, lines []string) { +func validateUnusedConditions(errs *ValidationErrors, validator *ConditionValidator, lines []string) { for _, conditionName := range slices.Sorted(maps.Keys(validator.definedConds)) { if !validator.usedConds[conditionName] { condition := validator.definedConds[conditionName] @@ -90,20 +90,20 @@ func validateUnusedConditions(collector *ErrorCollector, validator *ConditionVal File: condition.GetMetadata().GetSourceInfo().GetFile(), Module: condition.GetMetadata().GetModule(), } - collector.RaiseUnusedCondition(conditionName, meta, lineIndex) + errs.Add(newUnusedConditionError(lines, conditionName, meta, lineIndex)) } } } // ValidateConditionReferences validates that all referenced conditions are defined. -func ValidateConditionReferences(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateConditionReferences(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } - validateConditionReferences(collector, NewConditionValidator(model), lines) + validateConditionReferences(errs, NewConditionValidator(model), lines) } -func validateConditionReferences(collector *ErrorCollector, validator *ConditionValidator, lines []string) { +func validateConditionReferences(errs *ValidationErrors, validator *ConditionValidator, lines []string) { model := validator.model for _, conditionName := range slices.Sorted(maps.Keys(validator.usedConds)) { if _, exists := validator.definedConds[conditionName]; !exists { @@ -122,7 +122,7 @@ func validateConditionReferences(collector *ErrorCollector, validator *Condition } } meta := &Meta{File: file, Module: module} - collector.RaiseInvalidConditionNameInParameter(conditionName, ref.TypeName, ref.RelationName, conditionName, meta, lineIndex) + errs.Add(newInvalidConditionNameInParameterError(lines, conditionName, ref.TypeName, ref.RelationName, conditionName, meta, lineIndex)) } } } @@ -131,7 +131,7 @@ func validateConditionReferences(collector *ErrorCollector, validator *Condition // 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) { +func ValidateConditionConsistency(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } @@ -142,7 +142,7 @@ func ValidateConditionConsistency(collector *ErrorCollector, model *openfgav1.Au continue } if condition.GetName() != conditionKey { - collector.RaiseDifferentNestedConditionName(conditionKey, condition.GetName()) + errs.Add(newDifferentNestedConditionNameError(conditionKey, condition.GetName())) } } } diff --git a/pkg/go/validation/condition_validation_test.go b/pkg/go/validation/condition_validation_test.go index d0940775..1a7fbb03 100644 --- a/pkg/go/validation/condition_validation_test.go +++ b/pkg/go/validation/condition_validation_test.go @@ -188,7 +188,7 @@ func TestValidateUnusedConditions(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateUnusedConditions(collector, model, nil) errors := collector.AllFindings() @@ -220,7 +220,7 @@ func TestValidateUnusedConditions(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateUnusedConditions(collector, model, nil) errors := collector.AllFindings() @@ -257,7 +257,7 @@ func TestValidateUnusedConditions(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateUnusedConditions(collector, model, nil) errors := collector.AllFindings() @@ -299,7 +299,7 @@ func TestValidateConditionReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateConditionReferences(collector, model, nil) errors := collector.AllFindings() @@ -327,7 +327,7 @@ func TestValidateConditionReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateConditionReferences(collector, model, nil) errors := collector.AllFindings() @@ -366,7 +366,7 @@ func TestValidateConditionReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateConditionReferences(collector, model, nil) errors := collector.AllFindings() @@ -391,7 +391,7 @@ func TestValidateConditionConsistency(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateConditionConsistency(collector, model, nil) errors := collector.AllFindings() @@ -405,7 +405,7 @@ func TestValidateConditionConsistency(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateConditionConsistency(collector, model, nil) errors := collector.AllFindings() @@ -421,7 +421,7 @@ func TestValidateConditionConsistency(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateConditionConsistency(collector, model, nil) assert.Empty(t, collector.AllFindings()) diff --git a/pkg/go/validation/cycle_detection.go b/pkg/go/validation/cycle_detection.go index 8563b50b..231dff22 100644 --- a/pkg/go/validation/cycle_detection.go +++ b/pkg/go/validation/cycle_detection.go @@ -32,14 +32,14 @@ func NewCycleDetector(validator *SemanticValidator) *CycleDetector { // 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) { +func ValidateCyclesAndEntryPoints(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } - validateCyclesAndEntryPoints(collector, NewSemanticValidator(model), lines) + validateCyclesAndEntryPoints(errs, NewSemanticValidator(model), lines) } -func validateCyclesAndEntryPoints(collector *ErrorCollector, validator *SemanticValidator, lines []string) { +func validateCyclesAndEntryPoints(errs *ValidationErrors, validator *SemanticValidator, lines []string) { model := validator.model if model == nil { return @@ -60,9 +60,9 @@ func validateCyclesAndEntryPoints(collector *ErrorCollector, validator *Semantic if !result.hasEntry { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) if result.loop { - collector.RaiseNoEntryPointLoop(relationName, typeName, meta, lineIndex) + errs.Add(newNoEntryPointLoopError(lines, relationName, typeName, meta, lineIndex)) } else { - collector.RaiseNoEntryPoint(relationName, typeName, meta, lineIndex) + errs.Add(newNoEntryPointError(lines, relationName, typeName, meta, lineIndex)) } } } diff --git a/pkg/go/validation/cycle_detection_stress_test.go b/pkg/go/validation/cycle_detection_stress_test.go index 6482b7f7..f883413c 100644 --- a/pkg/go/validation/cycle_detection_stress_test.go +++ b/pkg/go/validation/cycle_detection_stress_test.go @@ -54,7 +54,7 @@ func TestCycleDetection_DeepChainTerminatesWithEntry(t *testing.T) { t.Fatalf("failed to transform DSL: %v", err) } lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, lines) @@ -74,7 +74,7 @@ func TestCycleDetection_WideUnionTerminatesWithEntry(t *testing.T) { t.Fatalf("failed to transform DSL: %v", err) } lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, lines) @@ -101,7 +101,7 @@ type doc t.Fatalf("failed to transform DSL: %v", err) } lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, lines) @@ -129,7 +129,7 @@ type doc t.Fatalf("failed to transform DSL: %v", err) } lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, lines) @@ -163,7 +163,7 @@ func TestCycleDetection_DeepChainCountStable(t *testing.T) { t.Fatalf("failed to transform DSL: %v", err) } lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, lines) diff --git a/pkg/go/validation/cycle_detection_test.go b/pkg/go/validation/cycle_detection_test.go index f7f986ef..9d95f295 100644 --- a/pkg/go/validation/cycle_detection_test.go +++ b/pkg/go/validation/cycle_detection_test.go @@ -51,7 +51,7 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, nil) errors := collector.AllFindings() @@ -93,7 +93,7 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, nil) assert.Empty(t, collector.AllFindings()) }) @@ -118,7 +118,7 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateCyclesAndEntryPoints(collector, model, nil) // All three relations resolve to owner's direct assignment. assert.Empty(t, collector.AllFindings()) diff --git a/pkg/go/validation/duplicate_detection.go b/pkg/go/validation/duplicate_detection.go index e705c9fd..1b5a9419 100644 --- a/pkg/go/validation/duplicate_detection.go +++ b/pkg/go/validation/duplicate_detection.go @@ -16,11 +16,11 @@ func NewDuplicateTypeTracker() *DuplicateTypeTracker { return &DuplicateTypeTracker{typeNames: make(map[string]bool)} } -func (dt *DuplicateTypeTracker) CheckAndAddType(typeName string, collector *ErrorCollector, +func (dt *DuplicateTypeTracker) CheckAndAddType(typeName string, errs *ValidationErrors, meta *Meta, lines []string) bool { if dt.typeNames[typeName] { typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - collector.RaiseDuplicateTypeName(typeName, meta, typeLineIndex) + errs.Add(newDuplicateTypeNameError(lines, typeName, meta, typeLineIndex)) return false } dt.typeNames[typeName] = true @@ -28,7 +28,7 @@ func (dt *DuplicateTypeTracker) CheckAndAddType(typeName string, collector *Erro } // CheckForDuplicateTypeNamesInRelation checks for duplicate type restrictions within a relation. -func CheckForDuplicateTypeNamesInRelation(collector *ErrorCollector, relationMetadata *openfgav1.RelationMetadata, +func CheckForDuplicateTypeNamesInRelation(errs *ValidationErrors, relationMetadata *openfgav1.RelationMetadata, relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { if relationMetadata == nil { return @@ -49,7 +49,7 @@ func CheckForDuplicateTypeNamesInRelation(collector *ErrorCollector, relationMet } if typeRestrictions[typeRestrictionString] { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateTypeRestriction(typeRestrictionString, relationName, typeName, meta, lineIndex) + errs.Add(newDuplicateTypeRestrictionError(lines, typeRestrictionString, relationName, typeName, meta, lineIndex)) } else { typeRestrictions[typeRestrictionString] = true } @@ -57,7 +57,7 @@ func CheckForDuplicateTypeNamesInRelation(collector *ErrorCollector, relationMet } // CheckForDuplicatesInRelation checks for duplicate relations in type definitions. -func CheckForDuplicatesInRelation(collector *ErrorCollector, typeDef *openfgav1.TypeDefinition, +func CheckForDuplicatesInRelation(errs *ValidationErrors, typeDef *openfgav1.TypeDefinition, relationName string, typeLineIndex *int, lines []string) { if typeDef == nil { return @@ -84,20 +84,20 @@ func CheckForDuplicatesInRelation(collector *ErrorCollector, typeDef *openfgav1. meta := &Meta{File: file, Module: module} if union := relation.GetUnion(); union != nil { - checkDuplicatesInOperands(collector, union, relationName, typeDef.GetType(), meta, typeLineIndex, lines) + checkDuplicatesInOperands(errs, union, relationName, typeDef.GetType(), meta, typeLineIndex, lines) } if intersection := relation.GetIntersection(); intersection != nil { - checkDuplicatesInOperands(collector, intersection, relationName, typeDef.GetType(), meta, typeLineIndex, lines) + checkDuplicatesInOperands(errs, intersection, relationName, typeDef.GetType(), meta, typeLineIndex, lines) } if diff := relation.GetDifference(); diff != nil { - checkDuplicatesInDifference(collector, diff, relationName, typeDef.GetType(), meta, typeLineIndex, lines) + checkDuplicatesInDifference(errs, diff, relationName, typeDef.GetType(), meta, typeLineIndex, lines) } } // 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, +func checkDuplicatesInOperands(errs *ValidationErrors, operands *openfgav1.Usersets, relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { if operands == nil { return @@ -107,7 +107,7 @@ func checkDuplicatesInOperands(collector *ErrorCollector, operands *openfgav1.Us if relationDef := getRelationDefName(child); relationDef != "" { if relationDefs[relationDef] { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateType(relationDef, relationName, typeName, meta, lineIndex) + errs.Add(newDuplicateTypeError(lines, relationDef, relationName, typeName, meta, lineIndex)) } else { relationDefs[relationDef] = true } @@ -115,7 +115,7 @@ func checkDuplicatesInOperands(collector *ErrorCollector, operands *openfgav1.Us } } -func checkDuplicatesInDifference(collector *ErrorCollector, difference *openfgav1.Difference, +func checkDuplicatesInDifference(errs *ValidationErrors, difference *openfgav1.Difference, relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { if difference == nil { return @@ -124,7 +124,7 @@ func checkDuplicatesInDifference(collector *ErrorCollector, difference *openfgav subtractName := getRelationDefName(difference.GetSubtract()) if baseName != "" && baseName == subtractName { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateType(baseName, relationName, typeName, meta, lineIndex) + errs.Add(newDuplicateTypeError(lines, baseName, relationName, typeName, meta, lineIndex)) } } @@ -151,7 +151,7 @@ func getRelationDefName(userset *openfgav1.Userset) string { } // ValidateDuplicates performs comprehensive duplicate detection on a model. -func ValidateDuplicates(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateDuplicates(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } @@ -165,13 +165,13 @@ func ValidateDuplicates(collector *ErrorCollector, model *openfgav1.Authorizatio File: typeDef.GetMetadata().GetSourceInfo().GetFile(), Module: typeDef.GetMetadata().GetModule(), } - typeTracker.CheckAndAddType(typeName, collector, meta, lines) + typeTracker.CheckAndAddType(typeName, errs, meta, lines) typeLineIndex := GetTypeLineNumber(typeName, lines, nil) if metaProto := typeDef.GetMetadata(); metaProto != nil { relationsMetadata := metaProto.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { - CheckForDuplicateTypeNamesInRelation(collector, relationsMetadata[relationName], relationName, typeName, meta, typeLineIndex, lines) - CheckForDuplicatesInRelation(collector, typeDef, relationName, typeLineIndex, lines) + CheckForDuplicateTypeNamesInRelation(errs, relationsMetadata[relationName], relationName, typeName, meta, typeLineIndex, lines) + CheckForDuplicatesInRelation(errs, typeDef, relationName, typeLineIndex, lines) } } } diff --git a/pkg/go/validation/duplicate_detection_test.go b/pkg/go/validation/duplicate_detection_test.go index a177ba17..ea8f55a6 100644 --- a/pkg/go/validation/duplicate_detection_test.go +++ b/pkg/go/validation/duplicate_detection_test.go @@ -48,7 +48,7 @@ func TestDuplicateTypeTracker_CheckAndAddType(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tracker := NewDuplicateTypeTracker() - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) meta := &Meta{File: "test.fga", Module: "test"} for _, typeName := range tt.typeNames { @@ -165,7 +165,7 @@ func TestCheckForDuplicateTypeNamesInRelation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) meta := &Meta{File: "test.fga", Module: "test"} CheckForDuplicateTypeNamesInRelation(collector, tt.relationMetadata, tt.relationName, tt.typeName, meta, nil, nil) @@ -373,7 +373,7 @@ func TestCheckForDuplicatesInRelation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) CheckForDuplicatesInRelation(collector, tt.typeDef, tt.relationName, nil, nil) @@ -495,7 +495,7 @@ func TestValidateDuplicates(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateDuplicates(collector, tt.model, nil) @@ -577,7 +577,7 @@ func TestCheckDuplicatesInUnion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) meta := &Meta{File: "test.fga", Module: "test"} checkDuplicatesInOperands(collector, tt.union, "test_relation", "test_type", meta, nil, nil) @@ -594,7 +594,7 @@ func TestCheckDuplicatesInUnion(t *testing.T) { func TestValidateDuplicates_Integration(t *testing.T) { t.Run("Duplicate Type Detection", func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) // Model with duplicate type names model := &openfgav1.AuthorizationModel{ @@ -612,7 +612,7 @@ func TestValidateDuplicates_Integration(t *testing.T) { }) t.Run("Duplicate Type Restriction Detection", func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) // Model with duplicate type restrictions in relation model := &openfgav1.AuthorizationModel{ diff --git a/pkg/go/validation/error_builders.go b/pkg/go/validation/error_builders.go new file mode 100644 index 00000000..f09d3152 --- /dev/null +++ b/pkg/go/validation/error_builders.go @@ -0,0 +1,293 @@ +package validation + +import ( + "fmt" + "strings" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// newInvalidNameError reports a name that breaks a naming rule. A nil typeName means the +// offending name is a type rather than a relation on one, which changes the message and +// the scope. +func newInvalidNameError(lines []string, symbol, clause string, typeName *string, lineIndex *int, meta *Meta) *ValidationError { + var message string + errorScope := scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}} + + if typeName != nil { + message = fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", symbol, *typeName, clause) + errorScope = scope{part: &fgaerrors.ErrRelation{ObjectType: *typeName, Relation: symbol}} + } else { + message = fmt.Sprintf("type '%s' does not match naming rule: '%s'.", symbol, clause) + } + + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, InvalidName, symbol, line, column, errorScope, meta) +} + +// newInvalidConditionNameError reports a condition name that breaks a naming rule, +// scoped to the condition rather than a type or relation. +func newInvalidConditionNameError(lines []string, symbol, clause string, lineIndex *int, meta *Meta) *ValidationError { + message := fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", symbol, clause) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, InvalidName, symbol, line, column, scope{part: &fgaerrors.ErrCondition{Condition: symbol}}, meta) +} + +// newReservedTypeNameError reports a type named with a reserved keyword. +func newReservedTypeNameError(lines []string, symbol string, lineIndex *int, meta *Meta) *ValidationError { + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError("a type cannot be named 'self' or 'this'.", ReservedTypeKeywords, symbol, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}}, meta) +} + +// newReservedRelationNameError reports a relation named with a reserved keyword. +func newReservedRelationNameError(lines []string, symbol, typeName string, lineIndex *int, meta *Meta) *ValidationError { + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError("a relation cannot be named 'self' or 'this'.", ReservedRelationKeywords, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) +} + +// newTupleUsersetRequiresDirectError reports a tuple-to-userset that is not direct. Its +// column is resolved past the `from` keyword so it marks the offending relation. +func newTupleUsersetRequiresDirectError(lines []string, symbol, typeName, relation string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("`%s` relation used inside from allows only direct relation.", symbol) + + 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 + } + + line, column := resolvePosition(lines, symbol, lineIndex, customResolver) + return newValidationError(message, TuplesetNotDirect, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}}, meta) +} + +// newDuplicateTypeNameError reports a duplicated type. It is about the type, not a +// relation on it, so it overrides DuplicatedError's relation-scoped default. +func newDuplicateTypeNameError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("the type `%s` is a duplicate.", symbol) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}}, meta) +} + +// newDuplicateTypeRestrictionError reports a duplicated type restriction on a relation. +func newDuplicateTypeRestrictionError(lines []string, symbol, relationName, typeName string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("the type restriction `%s` is a duplicate in the relation `%s`.", symbol, relationName) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) +} + +// newUndefinedTypeError reports a reference to a type that does not exist. The scope +// names the type that is missing, not the relation it was referenced from. +func newUndefinedTypeError(lines []string, typeName, relationName, parentTypeName string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("Type '%s' is not defined (referenced in relation '%s' of type '%s')", typeName, relationName, parentTypeName) + line, column := resolvePosition(lines, typeName, lineIndex, nil) + return newValidationError(message, UndefinedType, typeName, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: typeName}}, meta) +} + +// newUndefinedRelationError reports a reference to a relation that does not exist on its +// type. +func newUndefinedRelationError(lines []string, relationName, typeName, parentRelation, parentTypeName string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("Relation '%s' is not defined on type '%s' (referenced in relation '%s' of type '%s')", relationName, typeName, parentRelation, parentTypeName) + line, column := resolvePosition(lines, relationName, lineIndex, nil) + return newValidationError(message, UndefinedRelation, relationName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) +} + +// newDuplicateTypeError reports a duplicated partial relation definition. +func newDuplicateTypeError(lines []string, symbol, relationName, typeName string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("the partial relation definition `%s` is a duplicate in the relation `%s`.", + symbol, relationName) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) +} + +// newDuplicateRelationshipDefinitionError reports a relation defined more than once. +func newDuplicateRelationshipDefinitionError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("the relation '%s' is defined more than once.", symbol) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{Relation: symbol}}, meta) +} + +// newNoEntryPointLoopError reports an impossible relation with a potential loop. +func newNoEntryPointLoopError(lines []string, symbol, typeName string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("`%s` is an impossible relation for `%s` (potential loop).", symbol, typeName) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, RelationNoEntrypoint, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) +} + +// newNoEntryPointError reports an impossible relation with no entry point. +func newNoEntryPointError(lines []string, symbol, typeName string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("`%s` is an impossible relation for `%s` (no entrypoint).", symbol, typeName) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, RelationNoEntrypoint, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) +} + +// newInvalidRelationOnTuplesetError reports a tupleset relation whose target does not +// exist on the referenced type. +func newInvalidRelationOnTuplesetError(lines []string, symbol, typeName, typeDef, relationName, + offendingRelation, parent string, lineIndex *int, meta *Meta) *ValidationError { + 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) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, InvalidRelationOnTupleset, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeDef, Relation: relationName}}, meta) +} + +// newInvalidTypeRelationError reports a relation reference that is not valid for a type. +// Its offendingType argument is the enclosing type the reference was written in, kept as +// metadata. +func newInvalidTypeRelationError(lines []string, symbol, typeName, relationName, offendingRelation, + offendingType string, lineIndex *int, meta *Meta) *ValidationError { + message := fmt.Sprintf("`%s` is not a valid relation for `%s`.", offendingRelation, typeName) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, InvalidRelationType, symbol, line, column, scope{ + part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, + offendingType: offendingType, + }, meta) +} + +// newInvalidTypeError reports an invalid type in an assignable-types list. Its column is +// resolved to the value side of the colon so it marks the type, not a relation key that +// shares its name. +func newInvalidTypeError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("`%s` is not a valid type.", symbol) + 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 + } + line, column := resolvePosition(lines, symbol, lineIndex, resolver) + return newValidationError(message, InvalidType, symbol, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}}, meta) +} + +// newAssignableRelationMustHaveTypesError reports an assignable relation with no +// assignable type. +func newAssignableRelationMustHaveTypesError(lines []string, symbol string, lineIndex *int) *ValidationError { + message := fmt.Sprintf("the assignable relation '%s' must have at least one assignable type.", symbol) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, AssignableRelationsMustHaveType, symbol, line, column, scope{part: &fgaerrors.ErrRelation{Relation: symbol}}, nil) +} + +// newAssignableTypeWildcardRelationError reports a type restriction that carries both a +// wildcard and a relation. +func newAssignableTypeWildcardRelationError(lines []string, symbol, typeName, relation string, meta *Meta, lineIndex *int) *ValidationError { + 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) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, TypeRestrictionCannotHaveWildcardAndRelation, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}}, meta) +} + +// newInvalidRelationError reports a rewrite that names a relation the type does not +// define. The message names the missing relation only, as the reference's does. +func newInvalidRelationError(lines []string, symbol, typeName, relation string, + lineIndex *int, meta *Meta) *ValidationError { + message := fmt.Sprintf("the relation `%s` does not exist.", symbol) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, MissingDefinition, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}}, meta) +} + +// newInvalidSchemaVersionError 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 newInvalidSchemaVersionError(lines []string, symbol string, lineIndex *int) *ValidationError { + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(fmt.Sprintf("invalid schema %s", symbol), InvalidSchema, symbol, line, column, scope{}, nil) +} + +// newSchemaVersionUnsupportedError reports a recognized but retired schema version +// (e.g. "1.0"). +func newSchemaVersionUnsupportedError(lines []string, symbol string, lineIndex *int) *ValidationError { + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError("schema version no longer supported", SchemaVersionUnsupported, symbol, line, column, scope{}, nil) +} + +// newSchemaVersionRequiredError reports a model with no schema version. It names no part +// of the model, so it is about the model as a whole. +func newSchemaVersionRequiredError(lines []string, lineIndex *int) *ValidationError { + line, column := resolvePosition(lines, "", lineIndex, nil) + return newValidationError("schema version required", SchemaVersionRequired, "", line, column, scope{}, nil) +} + +// newMaximumOneDirectRelationshipError reports a relation with more than one direct +// relationship. +func newMaximumOneDirectRelationshipError(lines []string, symbol string, lineIndex *int) *ValidationError { + message := fmt.Sprintf("the relation '%s' can have at most one direct relationship.", symbol) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{Relation: symbol}}, nil) +} + +// newInvalidConditionNameInParameterError reports a reference to a condition that is not +// defined. It is scoped to the relation the condition is applied to, since the condition +// has no definition to point at. +func newInvalidConditionNameInParameterError(lines []string, symbol, typeName, relationName, conditionName string, + meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("`%s` is not a defined condition in the model.", conditionName) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, ConditionNotDefined, symbol, line, column, scope{part: &fgaerrors.ErrRelationCondition{ObjectType: typeName, Relation: relationName, Condition: conditionName}}, meta) +} + +// newUnusedConditionError reports a condition defined but never referenced. +func newUnusedConditionError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("`%s` condition is not used in the model.", symbol) + line, column := resolvePosition(lines, symbol, lineIndex, nil) + return newValidationError(message, ConditionNotUsed, symbol, line, column, scope{part: &fgaerrors.ErrCondition{Condition: symbol}}, meta) +} + +// newDifferentNestedConditionNameError reports a condition whose nested name property +// differs from its map key. It carries no position, matching the reference. +func newDifferentNestedConditionNameError(condition, nestedConditionName string) *ValidationError { + message := fmt.Sprintf("condition key is `%s` but nested name property is %s", condition, nestedConditionName) + return newValidationError(message, DifferentNestedConditionName, nestedConditionName, nil, nil, scope{part: &fgaerrors.ErrCondition{Condition: condition}}, nil) +} + +// newMultipleModulesInSingleFileError reports a file that would contain more than one +// module. It names no part of the model, so it is about the model as a whole. +func newMultipleModulesInSingleFileError(file string, modules []string) *ValidationError { + moduleList := strings.Join(modules, ", ") + message := fmt.Sprintf("file %s would contain multiple module definitions (%s) when transforming to DSL. "+ + "Only one module can be defined per file.", file, moduleList) + return newValidationError(message, MultipleModulesInFile, file, nil, nil, scope{}, nil) +} + +// newRedundantUnionMemberError reports a redundant member in a union operation. +func newRedundantUnionMemberError(lines []string, operation, relationName, typeName string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("Redundant operation '%s' found in union for relation '%s' of type '%s'", operation, relationName, typeName) + line, column := resolvePosition(lines, operation, lineIndex, nil) + return newValidationError(message, DuplicatedError, operation, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) +} + +// newImpossibleIntersectionError reports an intersection operation that cannot succeed. +func newImpossibleIntersectionError(lines []string, relationName, typeName string, conflictingTypes []string, meta *Meta, lineIndex *int) *ValidationError { + typeList := strings.Join(conflictingTypes, ", ") + message := fmt.Sprintf("Impossible intersection in relation '%s' of type '%s': conflicting types [%s]", relationName, typeName, typeList) + line, column := resolvePosition(lines, relationName, lineIndex, nil) + return newValidationError(message, InvalidRelationType, relationName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) +} + +// newEmptyDifferenceError reports a difference operation that results in an empty set. +func newEmptyDifferenceError(lines []string, relationName, typeName, operation string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("Empty difference operation in relation '%s' of type '%s': subtracting '%s' from itself", relationName, typeName, operation) + line, column := resolvePosition(lines, relationName, lineIndex, nil) + return newValidationError(message, RelationNoEntrypoint, relationName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) +} + +// newInvalidWildcardUsageError reports a wildcard used where it is not allowed. The +// wildcard is written in a relation of parentTypeName; typeName is the restriction it +// appears in, which the symbol already records. +func newInvalidWildcardUsageError(lines []string, typeName, relationName, parentTypeName, reason string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("Invalid wildcard usage for type '%s' in relation '%s' of type '%s': %s", + typeName, relationName, parentTypeName, reason) + line, column := resolvePosition(lines, typeName, lineIndex, nil) + return newValidationError(message, InvalidWildcardError, typeName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: parentTypeName, Relation: relationName}}, meta) +} + +// newTuplesetNotDirectError reports a tupleset relation that does not allow direct +// assignment. +func newTuplesetNotDirectError(lines []string, tuplesetRelation, typeName, parentRelation string, meta *Meta, lineIndex *int) *ValidationError { + message := fmt.Sprintf("Tupleset relation '%s' on type '%s' must allow direct assignment (used in relation '%s')", + tuplesetRelation, typeName, parentRelation) + line, column := resolvePosition(lines, tuplesetRelation, lineIndex, nil) + return newValidationError(message, TuplesetNotDirect, tuplesetRelation, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: tuplesetRelation}}, meta) +} diff --git a/pkg/go/validation/error_builders_test.go b/pkg/go/validation/error_builders_test.go new file mode 100644 index 00000000..02d4a9b9 --- /dev/null +++ b/pkg/go/validation/error_builders_test.go @@ -0,0 +1,399 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +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 TestValidationErrors_AllFindings(t *testing.T) { + errs := NewValidationErrors(nil) + + // Initially no errors + assert.Empty(t, errs.AllFindings()) + + // Add an error + errs.Add(newInvalidNameError(nil, "test", "rule", nil, nil, nil)) + + findings := errs.AllFindings() + assert.Len(t, findings, 1) + assert.Contains(t, findings[0].Message, "test") +} + +func TestValidationErrors_HasErrorsAfterAdd(t *testing.T) { + errs := NewValidationErrors(nil) + + assert.False(t, errs.HasErrors()) + + errs.Add(newInvalidNameError(nil, "test", "rule", nil, nil, nil)) + + assert.True(t, errs.HasErrors()) +} + +func TestValidationErrors_CountAfterAdd(t *testing.T) { + errs := NewValidationErrors(nil) + + assert.Equal(t, 0, errs.Count()) + + errs.Add(newInvalidNameError(nil, "test1", "rule", nil, nil, nil)) + assert.Equal(t, 1, errs.Count()) + + errs.Add(newInvalidNameError(nil, "test2", "rule", nil, nil, nil)) + assert.Equal(t, 2, errs.Count()) +} + +func TestNewInvalidNameError(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) { + errs := NewValidationErrors(nil) + errs.Add(newInvalidNameError(nil, tt.symbol, tt.clause, tt.typeName, tt.lineIndex, tt.meta)) + + findings := errs.AllFindings() + assert.Len(t, findings, 1) + assert.Equal(t, tt.expectedMsg, findings[0].Message) + assert.Equal(t, tt.expectedType, findings[0].Metadata.ErrorType) + assert.Equal(t, tt.symbol, findings[0].Metadata.Symbol) + }) + } +} + +func TestNewInvalidConditionNameError(t *testing.T) { + lineIndex := 5 + meta := &Meta{File: "test.fga", Module: "test"} + + err := newInvalidConditionNameError(nil, "bad name", "[a-zA-Z]+", &lineIndex, meta) + + assert.Equal(t, "condition 'bad name' does not match naming rule: '[a-zA-Z]+'.", err.Message) + assert.Equal(t, InvalidName, err.Metadata.ErrorType) + assert.Equal(t, "bad name", err.Metadata.Symbol) + assert.Equal(t, fgaerrors.ErrorKindCondition, err.Category) + assert.Equal(t, "bad name", err.Metadata.Condition) + assert.Empty(t, err.Metadata.Type) + + var scoped *fgaerrors.ErrCondition + require.ErrorAs(t, err, &scoped) + assert.Equal(t, "bad name", scoped.Condition) +} + +func TestNewReservedTypeNameError(t *testing.T) { + lineIndex := 5 + meta := &Meta{File: "test.fga", Module: "test"} + + err := newReservedTypeNameError(nil, "self", &lineIndex, meta) + + assert.Equal(t, "a type cannot be named 'self' or 'this'.", err.Message) + assert.Equal(t, ReservedTypeKeywords, err.Metadata.ErrorType) + assert.Equal(t, "self", err.Metadata.Symbol) + assert.Equal(t, "test.fga", err.File) +} + +func TestNewReservedRelationNameError(t *testing.T) { + lineIndex := 3 + meta := &Meta{File: "test.fga", Module: "test"} + + err := newReservedRelationNameError(nil, "this", "document", &lineIndex, meta) + + assert.Equal(t, "a relation cannot be named 'self' or 'this'.", err.Message) + assert.Equal(t, ReservedRelationKeywords, err.Metadata.ErrorType) + assert.Equal(t, "this", err.Metadata.Symbol) + assert.Equal(t, "document", err.Metadata.Type) +} + +func TestNewTupleUsersetRequiresDirectError(t *testing.T) { + lines := []string{ + "type document", + " relations", + " define viewer: user from parent", + " define admin: [user]", + } + lineIndex := 2 + meta := &Meta{File: "test.fga"} + + err := newTupleUsersetRequiresDirectError(lines, "user", "document", "viewer", meta, &lineIndex) + + assert.Equal(t, "`user` relation used inside from allows only direct relation.", err.Message) + assert.Equal(t, TuplesetNotDirect, err.Metadata.ErrorType) + assert.Equal(t, "user", err.Metadata.Symbol) +} + +func TestNewDuplicateTypeNameError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 10 + + err := newDuplicateTypeNameError(nil, "document", meta, &lineIndex) + + assert.Equal(t, "the type `document` is a duplicate.", err.Message) + assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) + assert.Equal(t, "document", err.Metadata.Symbol) +} + +func TestNewDuplicateTypeRestrictionError(t *testing.T) { + meta := &Meta{File: "test.fga"} + lineIndex := 5 + + err := newDuplicateTypeRestrictionError(nil, "user", "viewer", "document", meta, &lineIndex) + + assert.Equal(t, "the type restriction `user` is a duplicate in the relation `viewer`.", err.Message) + assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) + assert.Equal(t, "user", err.Metadata.Symbol) +} + +func TestNewNoEntryPointLoopError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 8 + + err := newNoEntryPointLoopError(nil, "viewer", "document", meta, &lineIndex) + + assert.Equal(t, "`viewer` is an impossible relation for `document` (potential loop).", err.Message) + assert.Equal(t, RelationNoEntrypoint, err.Metadata.ErrorType) + assert.Equal(t, "viewer", err.Metadata.Symbol) +} + +func TestNewNoEntryPointError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 12 + + err := newNoEntryPointError(nil, "viewer", "document", meta, &lineIndex) + + assert.Equal(t, "`viewer` is an impossible relation for `document` (no entrypoint).", err.Message) + assert.Equal(t, RelationNoEntrypoint, err.Metadata.ErrorType) + assert.Equal(t, "viewer", err.Metadata.Symbol) +} + +func TestNewInvalidTypeError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 3 + + err := newInvalidTypeError(nil, "unknown_type", meta, &lineIndex) + + assert.Equal(t, "`unknown_type` is not a valid type.", err.Message) + assert.Equal(t, InvalidType, err.Metadata.ErrorType) + assert.Equal(t, "unknown_type", err.Metadata.Symbol) +} + +func TestNewAssignableRelationMustHaveTypesError(t *testing.T) { + lineIndex := 6 + + err := newAssignableRelationMustHaveTypesError(nil, "viewer", &lineIndex) + + assert.Equal(t, "the assignable relation 'viewer' must have at least one assignable type.", err.Message) + assert.Equal(t, AssignableRelationsMustHaveType, err.Metadata.ErrorType) + assert.Equal(t, "viewer", err.Metadata.Symbol) +} + +func TestNewInvalidRelationError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 4 + + err := newInvalidRelationError(nil, "unknown", "document", "relation", &lineIndex, meta) + + assert.Equal(t, "the relation `unknown` does not exist.", err.Message) + assert.Equal(t, MissingDefinition, err.Metadata.ErrorType) + assert.Equal(t, "unknown", err.Metadata.Symbol) +} + +func TestNewSchemaVersionRequiredError(t *testing.T) { + lineIndex := 0 + + err := newSchemaVersionRequiredError(nil, &lineIndex) + + assert.Equal(t, "schema version required", err.Message) + assert.Equal(t, SchemaVersionRequired, err.Metadata.ErrorType) +} + +func TestNewInvalidSchemaVersionError(t *testing.T) { + lineIndex := 1 + + err := newInvalidSchemaVersionError(nil, "2.0", &lineIndex) + + assert.Equal(t, "invalid schema 2.0", err.Message) + assert.Equal(t, InvalidSchema, err.Metadata.ErrorType) + assert.Equal(t, "2.0", err.Metadata.Symbol) +} + +func TestNewSchemaVersionUnsupportedError(t *testing.T) { + lineIndex := 1 + + err := newSchemaVersionUnsupportedError(nil, "1.0", &lineIndex) + + assert.Equal(t, "schema version no longer supported", err.Message) + assert.Equal(t, SchemaVersionUnsupported, err.Metadata.ErrorType) + assert.Equal(t, "1.0", err.Metadata.Symbol) +} + +func TestNewUnusedConditionError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 15 + + err := newUnusedConditionError(nil, "unused_condition", meta, &lineIndex) + + assert.Equal(t, "`unused_condition` condition is not used in the model.", err.Message) + assert.Equal(t, ConditionNotUsed, err.Metadata.ErrorType) + assert.Equal(t, "unused_condition", err.Metadata.Symbol) +} + +func TestNewDifferentNestedConditionNameError(t *testing.T) { + err := newDifferentNestedConditionNameError("condition1", "condition2") + + assert.Equal(t, "condition key is `condition1` but nested name property is condition2", err.Message) + assert.Equal(t, DifferentNestedConditionName, err.Metadata.ErrorType) + assert.Equal(t, "condition2", err.Metadata.Symbol) +} + +func TestNewMultipleModulesInSingleFileError(t *testing.T) { + modules := []string{"module1", "module2", "module3"} + + err := newMultipleModulesInSingleFileError("test.fga", modules) + + assert.Equal(t, "file test.fga would contain multiple module definitions (module1, module2, module3) "+ + "when transforming to DSL. Only one module can be defined per file.", err.Message) + assert.Equal(t, MultipleModulesInFile, err.Metadata.ErrorType) + assert.Equal(t, "test.fga", err.Metadata.Symbol) +} + +func TestLineAndColumnResolution(t *testing.T) { + lines := []string{ + "model", + " schema 1.1", + "type document", + " relations", + " define viewer: [user]", + } + lineIndex := 4 + + err := newInvalidNameError(lines, "viewer", "rule", nil, &lineIndex, nil) + + // Check line information + assert.NotNil(t, err.Line) + assert.Equal(t, 4, err.Line.Start) + assert.Equal(t, 4, err.Line.End) + + // Check column information (should find "viewer" in the line) + assert.NotNil(t, err.Column) + line := lines[4] + expectedStart := strings.Index(line, "viewer") + assert.Equal(t, expectedStart, err.Column.Start) + assert.Equal(t, expectedStart+len("viewer"), err.Column.End) +} + +func TestCustomResolver(t *testing.T) { + lines := []string{ + "type document", + " relations", + " define viewer: user from parent", + } + lineIndex := 2 + meta := &Meta{File: "test.fga"} + + err := newTupleUsersetRequiresDirectError(lines, "user", "document", "viewer", meta, &lineIndex) + + // The custom resolver should position the error after the "from" keyword + assert.NotNil(t, err.Column) + line := lines[2] + fromIndex := strings.Index(line, "from") + expectedStart := fromIndex + len("from") + strings.Index(line[fromIndex+len("from"):], "user") + assert.Equal(t, expectedStart, err.Column.Start) +} + +func TestNewUndefinedRelationError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 4 + + err := newUndefinedRelationError(nil, "viewer", "document", "can_view", "folder", meta, &lineIndex) + + assert.Equal(t, "Relation 'viewer' is not defined on type 'document' (referenced in relation 'can_view' of type 'folder')", err.Message) + assert.Equal(t, UndefinedRelation, err.Metadata.ErrorType) + assert.Equal(t, "viewer", err.Metadata.Symbol) + assert.Equal(t, "document", err.Metadata.Type) + assert.Equal(t, "viewer", err.Metadata.Relation) +} + +func TestNewDuplicateRelationshipDefinitionError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 7 + + err := newDuplicateRelationshipDefinitionError(nil, "viewer", meta, &lineIndex) + + assert.Equal(t, "the relation 'viewer' is defined more than once.", err.Message) + assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) + assert.Equal(t, "viewer", err.Metadata.Symbol) + assert.Equal(t, "viewer", err.Metadata.Relation) +} + +func TestNewAssignableTypeWildcardRelationError(t *testing.T) { + meta := &Meta{File: "test.fga", Module: "test"} + lineIndex := 9 + + err := newAssignableTypeWildcardRelationError(nil, "user", "document", "viewer", meta, &lineIndex) + + assert.Equal(t, "the type restriction 'user' on relation 'viewer' of type 'document' is not allowed to have both a wildcard and a relation.", err.Message) + assert.Equal(t, TypeRestrictionCannotHaveWildcardAndRelation, err.Metadata.ErrorType) + assert.Equal(t, "user", err.Metadata.Symbol) + assert.Equal(t, "document", err.Metadata.Type) + assert.Equal(t, "viewer", err.Metadata.Relation) +} + +func TestNewMaximumOneDirectRelationshipError(t *testing.T) { + lineIndex := 11 + + err := newMaximumOneDirectRelationshipError(nil, "viewer", &lineIndex) + + assert.Equal(t, "the relation 'viewer' can have at most one direct relationship.", err.Message) + assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) + assert.Equal(t, "viewer", err.Metadata.Symbol) + assert.Equal(t, "viewer", err.Metadata.Relation) +} diff --git a/pkg/go/validation/error_collector.go b/pkg/go/validation/error_collector.go deleted file mode 100644 index 5b68ddd1..00000000 --- a/pkg/go/validation/error_collector.go +++ /dev/null @@ -1,507 +0,0 @@ -package validation - -import ( - "fmt" - "strings" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// 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, - } -} - -// AllFindings returns every collected finding, blocking or not. The collector is the -// raw record; ValidationErrors is where findings are filtered by severity, which is -// why this is not called GetErrors: on ValidationErrors that name means the blocking -// ones only. -func (c *ErrorCollector) AllFindings() []*ValidationError { - return c.errors -} - -// HasErrors reports whether any collected finding makes the model invalid. -// -// The cascade in RunAllValidations gates on this, so it counts blocking findings -// only: one advisory must not skip every phase that runs after it. -func (c *ErrorCollector) HasErrors() bool { - for _, err := range c.errors { - if err.Blocks() { - return true - } - } - return false -} - -// Count returns the number of collected findings that make the model invalid. -func (c *ErrorCollector) Count() int { - count := 0 - for _, err := range c.errors { - if err.Blocks() { - count++ - } - } - return count -} - -// CountAll returns the total number of collected findings, blocking or not. -func (c *ErrorCollector) CountAll() int { - return len(c.errors) -} - -// scope is what a raise site knows that the collector cannot work out: which part of -// the model is at fault, and the enclosing type for the metadata. -type scope struct { - // part names the part of the model at fault. The raise site builds it, because - // the code alone does not say which part: duplicated-error is raised about a type - // from one place and a relation from another, and invalid-name about all three. - // The sentinel is filled in from the code's table entry, so at this point it - // wraps nothing. - part fgaerrors.ModelError - - // offendingType is the enclosing type a finding about another type was written - // in, matching JS's wire field of the same name. Metadata only: none of the - // scope types has a slot for it. - offendingType string -} - -// addError adds a finding that names no part of the model, so it is about the model as -// a whole. A raise site that names one, carries file or module metadata, or resolves -// its own column goes through addScopedError instead; none of the codes raised through -// here does any of those. -func (c *ErrorCollector) addError(message string, errorType ValidationErrorType, symbol string, - lineIndex *int) { - c.addScopedError(message, errorType, symbol, lineIndex, nil, nil, scope{ - part: &fgaerrors.ErrModel{}, - }) -} - -// addScopedError resolves where a finding points and records it. The code decides its -// severity and the sentinel it wraps; the raise site's scope decides which part of the -// model it names, and both the category and the metadata are read back off that. -func (c *ErrorCollector) addScopedError(message string, errorType ValidationErrorType, symbol string, - lineIndex *int, meta *Meta, customResolver ErrorCustomResolver, errorScope scope) { - line, column := c.position(symbol, lineIndex, customResolver) - - part := errorScope.part - if part == nil { - // A raise site that named nothing. Treat it as being about the model as a - // whole, which is what a code with no scope means. - part = &fgaerrors.ErrModel{} - } - - entry := lookupErrorInfo(errorType) - partScope := part.Scope() - - metadata := &ErrorMetadata{ - Symbol: symbol, - ErrorType: errorType, - OffendingType: errorScope.offendingType, - Type: partScope.ObjectType, - Relation: partScope.Relation, - Condition: partScope.Condition, - } - - if meta != nil { - // Module goes in the metadata, file on the error itself, matching the - // JS implementation. - metadata.Module = meta.Module - } - - validationErr := &ValidationError{ - Message: message, - Severity: entry.Severity, - Category: part.Kind(), - Line: line, - Column: column, - Metadata: metadata, - - // A code missing from the table has no sentinel, so there is nothing for - // errors.Is to match and this is nil. The category and metadata above still - // report what the raise site named. - Cause: fgaerrors.WithSentinel(part, entry.Cause), - } - - if meta != nil { - validationErr.File = meta.File - } - - c.errors = append(c.errors, validationErr) -} - -// position resolves the line and column a finding points at, both nil when the raise -// site gave no line or the line is outside the source. -func (c *ErrorCollector) position(symbol string, lineIndex *int, - customResolver ErrorCustomResolver) (line, column *Range) { - if lineIndex == nil || *lineIndex < 0 || *lineIndex >= len(c.lines) { - return nil, nil - } - - line = &Range{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 = &Range{ - Start: symbolPos, - End: symbolPos + len(symbol), - } - } - - return line, column -} - -// RaiseInvalidName raises an invalid name error. -func (c *ErrorCollector) RaiseInvalidName(symbol, clause string, typeName *string, lineIndex *int, meta *Meta) { - var message string - // A nil typeName means the offending name is a type rather than a relation on - // one, which changes both the message and the scope of the finding. - errorScope := scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}} - - if typeName != nil { - message = fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", symbol, *typeName, clause) - errorScope = scope{part: &fgaerrors.ErrRelation{ObjectType: *typeName, Relation: symbol}} - } else { - message = fmt.Sprintf("type '%s' does not match naming rule: '%s'.", symbol, clause) - } - - c.addScopedError(message, InvalidName, symbol, lineIndex, meta, nil, errorScope) -} - -// RaiseInvalidConditionName raises an invalid name error for a condition, scoped -// to the condition rather than RaiseInvalidName's type or relation. -func (c *ErrorCollector) RaiseInvalidConditionName(symbol, clause string, lineIndex *int, meta *Meta) { - message := fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", symbol, clause) - c.addScopedError(message, InvalidName, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrCondition{Condition: symbol}, - }) -} - -// 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.addScopedError(message, ReservedTypeKeywords, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrObjectType{ObjectType: symbol}, - }) -} - -// RaiseReservedRelationName raises a reserved relation name error. -func (c *ErrorCollector) RaiseReservedRelationName(symbol, typeName string, lineIndex *int, meta *Meta) { - message := "a relation cannot be named 'self' or 'this'." - c.addScopedError(message, ReservedRelationKeywords, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}, - }) -} - -// 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.addScopedError(message, TuplesetNotDirect, symbol, lineIndex, meta, customResolver, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}, - }) -} - -// 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) - // A duplicate type is about the type, not a relation on it, so this overrides - // DuplicatedError's relation-scoped default. - c.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrObjectType{ObjectType: symbol}, - }) -} - -// 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.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - }) -} - -// 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) - // The undefined type is the subject; parentTypeName is only where it was - // referenced from, so the scope names the type that does not exist. - c.addScopedError(message, UndefinedType, typeName, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrObjectType{ObjectType: typeName}, - }) -} - -// 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.addScopedError(message, UndefinedRelation, relationName, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - }) -} - -// 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.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - }) -} - -// 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.addScopedError(message, DuplicatedError, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{Relation: symbol}, - }) -} - -// 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.addScopedError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}, - }) -} - -// 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.addScopedError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}, - }) -} - -// 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.addScopedError(message, InvalidRelationOnTupleset, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeDef, Relation: relationName}, - }) -} - -// 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.addScopedError(message, InvalidRelationType, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - offendingType: offendingType, - }) -} - -// 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.addScopedError(message, InvalidType, symbol, lineIndex, meta, resolver, scope{ - part: &fgaerrors.ErrObjectType{ObjectType: symbol}, - }) -} - -// 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.addScopedError(message, AssignableRelationsMustHaveType, symbol, lineIndex, nil, nil, scope{ - part: &fgaerrors.ErrRelation{Relation: symbol}, - }) -} - -// 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.addScopedError(message, TypeRestrictionCannotHaveWildcardAndRelation, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}, - }) -} - -// RaiseInvalidRelationError reports a rewrite that names a relation the type does -// not define. The message names the missing relation only, as the reference's does; -// it does not list the relations that do exist. -func (c *ErrorCollector) RaiseInvalidRelationError(symbol, typeName, relation string, - lineIndex *int, meta *Meta) { - message := fmt.Sprintf("the relation `%s` does not exist.", symbol) - c.addScopedError(message, MissingDefinition, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}, - }) -} - -// 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) -} - -// 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) -} - -// 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) -} - -// 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.addScopedError(message, DuplicatedError, symbol, lineIndex, nil, nil, scope{ - part: &fgaerrors.ErrRelation{Relation: symbol}, - }) -} - -// 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) - // Scoped to the relation the condition is applied to, not the condition's own - // definition: the condition does not exist to have a definition. - c.addScopedError(message, ConditionNotDefined, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelationCondition{ObjectType: typeName, Relation: relationName, Condition: conditionName}, - }) -} - -// 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.addScopedError(message, ConditionNotUsed, symbol, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrCondition{Condition: symbol}, - }) -} - -// 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.addScopedError(message, DifferentNestedConditionName, nestedConditionName, nil, nil, nil, scope{ - part: &fgaerrors.ErrCondition{Condition: condition}, - }) -} - -// RaiseMultipleModulesInSingleFile raises an error for multiple modules in single -// file. The modules are listed in the order the model declares them, and the message -// mirrors the reference. -func (c *ErrorCollector) RaiseMultipleModulesInSingleFile(file string, modules []string) { - moduleList := strings.Join(modules, ", ") - message := fmt.Sprintf("file %s would contain multiple module definitions (%s) when transforming to DSL. "+ - "Only one module can be defined per file.", file, moduleList) - c.addError(message, MultipleModulesInFile, file, 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.addScopedError(message, DuplicatedError, operation, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - }) -} - -// 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.addScopedError(message, InvalidRelationType, relationName, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - }) -} - -// 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.addScopedError(message, RelationNoEntrypoint, relationName, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - }) -} diff --git a/pkg/go/validation/error_collector_test.go b/pkg/go/validation/error_collector_test.go deleted file mode 100644 index fcb52bde..00000000 --- a/pkg/go/validation/error_collector_test.go +++ /dev/null @@ -1,421 +0,0 @@ -package validation - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -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.AllFindings() - assert.Empty(t, errors) - - // Add an error - collector.RaiseInvalidName("test", "rule", nil, nil, nil) - - errors = collector.AllFindings() - 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.AllFindings() - 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_RaiseInvalidConditionName(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} - - collector.RaiseInvalidConditionName("bad name", "[a-zA-Z]+", &lineIndex, meta) - - errors := collector.AllFindings() - require.Len(t, errors, 1) - assert.Equal(t, "condition 'bad name' does not match naming rule: '[a-zA-Z]+'.", errors[0].Message) - assert.Equal(t, InvalidName, errors[0].Metadata.ErrorType) - assert.Equal(t, "bad name", errors[0].Metadata.Symbol) - assert.Equal(t, fgaerrors.ErrorKindCondition, errors[0].Category) - assert.Equal(t, "bad name", errors[0].Metadata.Condition) - assert.Empty(t, errors[0].Metadata.Type) - - var scoped *fgaerrors.ErrCondition - require.ErrorAs(t, errors[0], &scoped) - assert.Equal(t, "bad name", scoped.Condition) -} - -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.AllFindings() - 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", "document", &lineIndex, meta) - - errors := collector.AllFindings() - 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) - assert.Equal(t, "document", errors[0].Metadata.Type) -} - -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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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 - - collector.RaiseInvalidRelationError("unknown", "document", "relation", &lineIndex, meta) - - errors := collector.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - 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.AllFindings() - assert.Len(t, errors, 1) - assert.Equal(t, "file test.fga would contain multiple module definitions (module1, module2, module3) "+ - "when transforming to DSL. Only one module can be defined per file.", 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.AllFindings() - 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.AllFindings() - 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/error_construction.go b/pkg/go/validation/error_construction.go new file mode 100644 index 00000000..8a782cd8 --- /dev/null +++ b/pkg/go/validation/error_construction.go @@ -0,0 +1,150 @@ +package validation + +import ( + "strings" + + fgaerrors "github.com/openfga/language/pkg/go/errors" +) + +// 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') +} + +// scope is what a raise site knows that a finding's code cannot work out on its own: +// which part of the model is at fault, and the enclosing type for the metadata. +type scope struct { + // part names the part of the model at fault. The raise site builds it, because + // the code alone does not say which part: duplicated-error is raised about a type + // from one place and a relation from another, and invalid-name about all three. + // The sentinel is filled in from the code's table entry, so at this point it + // wraps nothing. + part fgaerrors.ModelError + + // offendingType is the enclosing type a finding about another type was written + // in, matching JS's wire field of the same name. Metadata only: none of the + // scope types has a slot for it. + offendingType string +} + +// newValidationError builds the finding a raise site describes: the code decides the +// severity and the sentinel it wraps, the scope decides which part of the model it +// names, and both the category and the metadata are read back off that. The line and +// column arguments are the already-resolved position, nil when the raise site gave none. +func newValidationError(message string, errorType ValidationErrorType, symbol string, + line, column *Range, errorScope scope, meta *Meta) *ValidationError { + part := errorScope.part + if part == nil { + // A raise site that named nothing. Treat it as being about the model as a + // whole, which is what a code with no scope means. + part = &fgaerrors.ErrModel{} + } + + entry := lookupErrorInfo(errorType) + partScope := part.Scope() + + metadata := &ErrorMetadata{ + Symbol: symbol, + ErrorType: errorType, + OffendingType: errorScope.offendingType, + Type: partScope.ObjectType, + Relation: partScope.Relation, + Condition: partScope.Condition, + } + + if meta != nil { + // Module goes in the metadata, file on the error itself, matching the + // JS implementation. + metadata.Module = meta.Module + } + + validationErr := &ValidationError{ + Message: message, + Severity: entry.Severity, + Category: part.Kind(), + Line: line, + Column: column, + Metadata: metadata, + + // A code missing from the table has no sentinel, so there is nothing for + // errors.Is to match and this is nil. The category and metadata above still + // report what the raise site named. + Cause: fgaerrors.WithSentinel(part, entry.Cause), + } + + if meta != nil { + validationErr.File = meta.File + } + + return validationErr +} + +// resolvePosition resolves the line and column a finding points at, both nil when the +// raise site gave no line or the line is outside the source. +func resolvePosition(lines []string, symbol string, lineIndex *int, + customResolver ErrorCustomResolver) (line, column *Range) { + if lineIndex == nil || *lineIndex < 0 || *lineIndex >= len(lines) { + return nil, nil + } + + line = &Range{Start: *lineIndex, End: *lineIndex} + + // Find symbol position in line for column calculation, matching on word + // boundaries as the reference does. + rawLine := lines[*lineIndex] + symbolPos := wordIndex(rawLine, symbol) + + if customResolver != nil { + symbolPos = customResolver(symbolPos, rawLine, symbol) + } + + if symbolPos >= 0 { + column = &Range{ + Start: symbolPos, + End: symbolPos + len(symbol), + } + } + + return line, column +} diff --git a/pkg/go/validation/error_info.go b/pkg/go/validation/error_info.go index 5b9aba15..55e4206a 100644 --- a/pkg/go/validation/error_info.go +++ b/pkg/go/validation/error_info.go @@ -18,7 +18,7 @@ type errorInfo struct { // errorInfoByType maps every code the validator emits to its severity, cause and // criticality. It is the only place those are decided, so a code cannot mean one -// thing in the collector and another in a report. +// thing at a raise site and another in a report. // // Which part of the model a finding is about is not here, because it does not follow // from the code. DuplicatedError covers a duplicate type and a duplicate type diff --git a/pkg/go/validation/error_info_integration_test.go b/pkg/go/validation/error_info_integration_test.go index 7553fcdf..061d65a7 100644 --- a/pkg/go/validation/error_info_integration_test.go +++ b/pkg/go/validation/error_info_integration_test.go @@ -294,7 +294,7 @@ type document // TestNonBlockingTableEntryReachesTheCaller closes the gap the other severity tests // leave: they assert what errorInfoByType holds, or build findings by hand, and every // entry is SeverityError today, so nothing follows a non-blocking severity from the -// table through addScopedError and out of an entry point. This downgrades one entry and +// table through a constructor and out of an entry point. This downgrades one entry and // does exactly that. // // It must not call t.Parallel: it mutates errorInfoByType, and Go runs a sequential diff --git a/pkg/go/validation/error_info_test.go b/pkg/go/validation/error_info_test.go index a71bc8f1..7b5900f7 100644 --- a/pkg/go/validation/error_info_test.go +++ b/pkg/go/validation/error_info_test.go @@ -25,7 +25,7 @@ import ( // page, and because SelfError and InvalidSyntax are equally unemitted in // pkg/js/errors.ts. A cycle with no entrypoint surfaces as RelationNoEntrypoint, // leaving CyclicError and CyclicRelation nothing to report. InvalidSchemaVersion is -// unreachable because RaiseInvalidSchemaVersion emits InvalidSchema, which is what +// unreachable because newInvalidSchemaVersionError emits InvalidSchema, which is what // the shared corpus expects. // // None get an errorInfoByType entry, so lookupErrorInfo treats them as blocking @@ -76,9 +76,9 @@ var allErrorTypes = []ValidationErrorType{ } // emittedErrorTypes parses this package's non-test sources and returns the name of -// every ValidationErrorType passed as the errorType argument of an addError call. It -// reads the source rather than a hand-written list, which would go stale in the same -// edit that leaves a code out of the table. +// every ValidationErrorType passed as the errorType argument of a newValidationError +// call. It reads the source rather than a hand-written list, which would go stale in +// the same edit that leaves a code out of the table. func emittedErrorTypes(t *testing.T) map[string]string { t.Helper() @@ -103,30 +103,20 @@ func emittedErrorTypes(t *testing.T) map[string]string { return true } - // Looking for c.addError(message, , ...) and its scoped - // variant. Both, because a raise site that gains scope moves from one - // to the other. - selector, ok := call.Fun.(*ast.SelectorExpr) - if !ok || len(call.Args) < 2 { - return true - } - - if selector.Sel.Name != "addError" && selector.Sel.Name != "addScopedError" { + // Every constructor names the finding's code as the second argument to + // newValidationError(message, , ...). That is the one place a + // code reaches a finding, so reading it here reports exactly the set a + // raise site can produce. + identFun, ok := call.Fun.(*ast.Ident) + if !ok || identFun.Name != "newValidationError" || len(call.Args) < 2 { return true } identifier, ok := call.Args[1].(*ast.Ident) - - // addError forwards its own errorType parameter to addScopedError: - // plumbing, not a raise site. - if ok && identifier.Name == "errorType" { - return true - } - if !ok { // A non-identifier errorType means the emitted set can't be // determined statically, and this test would silently under-report. - t.Errorf("%s: addError called with a non-constant errorType at %s; "+ + t.Errorf("%s: newValidationError called with a non-constant errorType at %s; "+ "emittedErrorTypes can no longer see what this emits", name, fileSet.Position(call.Args[1].Pos())) @@ -142,14 +132,14 @@ func emittedErrorTypes(t *testing.T) map[string]string { return emitted } -// TestErrorInfoCoversEveryEmittedErrorType checks every code a Raise* method can +// TestErrorInfoCoversEveryEmittedErrorType checks every code a constructor can // emit has a table entry, so any finding that reaches a caller has a cause to -// match. It fails when a new Raise* method is added without one. +// match. It fails when a new constructor is added without one. func TestErrorInfoCoversEveryEmittedErrorType(t *testing.T) { t.Parallel() emitted := emittedErrorTypes(t) - require.NotEmpty(t, emitted, "found no addError calls — the AST walk is broken, not the errorInfoByType") + require.NotEmpty(t, emitted, "found no newValidationError calls — the AST walk is broken, not the errorInfoByType") // Names, because the AST gives us identifiers and the table is keyed by value. classifiedNames := make(map[string]struct{}, len(errorInfoByType)) @@ -176,7 +166,7 @@ func TestErrorInfoHasNoUnemittedEntries(t *testing.T) { name := errorTypeConstantName(t, errorType) if _, ok := emitted[name]; !ok { t.Errorf("errorInfoByType has an entry for %s (%q) but nothing emits it — "+ - "either wire up the Raise* method or move it to unemittedErrorTypes", + "either wire up the constructor or move it to unemittedErrorTypes", name, errorType) } } diff --git a/pkg/go/validation/errors.go b/pkg/go/validation/errors.go index 84361673..075973da 100644 --- a/pkg/go/validation/errors.go +++ b/pkg/go/validation/errors.go @@ -87,7 +87,7 @@ type ValidationError struct { // Cause is the scoped error this finding wraps, and what Unwrap returns: // errors.Is identifies the condition, errors.As or Kind the part of the model. // It is nil for a finding whose code has no sentinel, and for one built directly - // rather than through the collector. + // rather than through a constructor. // // It stays off the wire because an error field has no concrete type to decode // into, which would leave ValidationError unable to round-trip. The message, @@ -105,7 +105,7 @@ func (e *ValidationError) Error() string { } // Unwrap returns Cause, which is nil for an error built directly rather than -// through the collector. +// through a constructor. func (e *ValidationError) Unwrap() error { return e.Cause } @@ -143,7 +143,7 @@ type ValidationErrors struct { // A nil *ValidationError is dropped, because it is not a finding: counting one would // have CountAll and HasFindings disagree with Blocks and Unwrap, and would put an // entry in the slice AllFindings hands out that dereferences nil on Severity or -// String. The collector never appends one; a collection built through +// String. No constructor returns one; a collection built through // NewValidationErrors, Add or the exported Errors field can hold one. // // The scan returns the slice untouched when there is nothing to drop, so the usual diff --git a/pkg/go/validation/errors_test.go b/pkg/go/validation/errors_test.go index a3fd01f7..a632d1b3 100644 --- a/pkg/go/validation/errors_test.go +++ b/pkg/go/validation/errors_test.go @@ -240,10 +240,10 @@ func TestMeta(t *testing.T) { func TestCategorySerialisesForEveryCategory(t *testing.T) { t.Parallel() - collector := NewErrorCollector(nil) - collector.RaiseInvalidType("user", "document", "viewer", nil, nil) // object-type - collector.RaiseDuplicateTypeRestriction("user", "viewer", "document", nil, nil) // relation - collector.RaiseUnusedCondition("unused_cond", nil, nil) // condition + collector := NewValidationErrors(nil) + collector.Add(newInvalidTypeError(nil, "user", nil, nil)) // object-type + collector.Add(newDuplicateTypeRestrictionError(nil, "user", "viewer", "document", nil, nil)) // relation + collector.Add(newUnusedConditionError(nil, "unused_cond", nil, nil)) // condition wantCategories := []string{`"category":"object-type"`, `"category":"relation"`, `"category":"condition"`} @@ -263,8 +263,8 @@ func TestCategorySerialisesForEveryCategory(t *testing.T) { func TestSeveritySerialisesUnderItsName(t *testing.T) { t.Parallel() - collector := NewErrorCollector(nil) - collector.RaiseInvalidType("user", "document", "viewer", nil, nil) + collector := NewValidationErrors(nil) + collector.Add(newInvalidTypeError(nil, "user", nil, nil)) findings := collector.AllFindings() require.Len(t, findings, 1) diff --git a/pkg/go/validation/keywords_test.go b/pkg/go/validation/keywords_test.go index ee42bc6d..ad170397 100644 --- a/pkg/go/validation/keywords_test.go +++ b/pkg/go/validation/keywords_test.go @@ -271,28 +271,28 @@ func TestReservedKeywordsMatchJSImplementation(t *testing.T) { } func TestReservedKeywordsValidation(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) // Test type name validation - should pass for valid names - isValid := ValidateTypeName("document", collector, nil, nil) + isValid := ValidateTypeName("document", collector, nil, nil, nil) assert.True(t, isValid) assert.Empty(t, collector.AllFindings()) // Test type name validation - should fail for reserved keywords - collector = NewErrorCollector(nil) - isValid = ValidateTypeName("this", collector, nil, nil) + collector = NewValidationErrors(nil) + isValid = ValidateTypeName("this", collector, nil, nil, nil) assert.False(t, isValid) assert.NotEmpty(t, collector.AllFindings()) // Test relation name validation - should pass for valid names - collector = NewErrorCollector(nil) - isValid = ValidateRelationName("viewer", "document", collector, nil, nil) + collector = NewValidationErrors(nil) + isValid = ValidateRelationName("viewer", "document", collector, nil, nil, nil) assert.True(t, isValid) assert.Empty(t, collector.AllFindings()) // Test relation name validation - should fail for reserved keywords - collector = NewErrorCollector(nil) - isValid = ValidateRelationName("self", "document", collector, nil, nil) + collector = NewValidationErrors(nil) + isValid = ValidateRelationName("self", "document", collector, nil, nil, nil) assert.False(t, isValid) assert.NotEmpty(t, collector.AllFindings()) } diff --git a/pkg/go/validation/multi_file_validation.go b/pkg/go/validation/multi_file_validation.go index 4ef9c7a6..0ef8551d 100644 --- a/pkg/go/validation/multi_file_validation.go +++ b/pkg/go/validation/multi_file_validation.go @@ -147,13 +147,13 @@ func (mfv *MultiFileValidator) addFileModuleMapping(file, module string) { } // ValidateMultiFileConsistency validates consistency across multiple files. -func ValidateMultiFileConsistency(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateMultiFileConsistency(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } // The rule itself lives in ValidateMultipleModulesInFile, which takes the files // this validator collected; the two must not drift. - ValidateMultipleModulesInFile(collector, NewMultiFileValidator(model).GetFileInfo()) + ValidateMultipleModulesInFile(errs, NewMultiFileValidator(model).GetFileInfo()) } func (mfv *MultiFileValidator) GetModuleInfo() []ModuleInfo { diff --git a/pkg/go/validation/multi_file_validation_test.go b/pkg/go/validation/multi_file_validation_test.go index f88b9ba0..286248e1 100644 --- a/pkg/go/validation/multi_file_validation_test.go +++ b/pkg/go/validation/multi_file_validation_test.go @@ -111,7 +111,7 @@ func TestMultiFileCollectionFollowsTheModelOrder(t *testing.T) { func TestValidateMultiFileConsistencyReportsEveryModuleInTheFile(t *testing.T) { t.Parallel() - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateMultiFileConsistency(collector, multiModuleModel(), nil) findings := collector.AllFindings() @@ -163,7 +163,7 @@ func TestRelationInheritsItsTypesFileAndModule(t *testing.T) { assert.False(t, validator.IsMultiModuleProject(), "both files hold the same module") assert.Equal(t, []string{"core.fga", "extra.fga"}, validator.GetFilesForModule("core")) - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateMultiFileConsistency(collector, model, nil) assert.Empty(t, collector.AllFindings(), "neither file holds more than one module") } @@ -275,13 +275,13 @@ func TestMultiFileValidatorWithoutModules(t *testing.T) { assert.False(t, validator.IsMultiFileProject()) assert.False(t, validator.IsMultiModuleProject()) - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateMultiFileConsistency(collector, model, nil) assert.Empty(t, collector.AllFindings()) // A nil model reaches the same entry point through the engine, and reports nothing // rather than panicking. - nilCollector := NewErrorCollector(nil) + nilCollector := NewValidationErrors(nil) ValidateMultiFileConsistency(nilCollector, nil, nil) assert.Empty(t, nilCollector.AllFindings()) assert.Empty(t, NewMultiFileValidator(nil).GetFileInfo()) diff --git a/pkg/go/validation/name_validation.go b/pkg/go/validation/name_validation.go index 87201ebe..df104596 100644 --- a/pkg/go/validation/name_validation.go +++ b/pkg/go/validation/name_validation.go @@ -44,17 +44,17 @@ var ( // 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 { +func ValidateTypeName(typeName string, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { // First check if it's a reserved keyword if IsReservedTypeName(typeName) { - collector.RaiseReservedTypeName(typeName, lineIndex, meta) + errs.Add(newReservedTypeNameError(lines, 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) + errs.Add(newInvalidNameError(lines, typeName, typeNameRule, nil, lineIndex, meta)) return false } @@ -63,17 +63,17 @@ func ValidateTypeName(typeName string, collector *ErrorCollector, lineIndex *int // 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 { +func ValidateRelationName(relationName, typeName string, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { // First check if it's a reserved keyword if IsReservedRelationName(relationName) { - collector.RaiseReservedRelationName(relationName, typeName, lineIndex, meta) + errs.Add(newReservedRelationNameError(lines, relationName, 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(relationNameRule, relationName) { - collector.RaiseInvalidName(relationName, relationNameRule, &typeName, lineIndex, meta) + errs.Add(newInvalidNameError(lines, relationName, relationNameRule, &typeName, lineIndex, meta)) return false } @@ -81,9 +81,9 @@ func ValidateRelationName(relationName, typeName string, collector *ErrorCollect } // ValidateConditionName validates a condition name with regex pattern. -func ValidateConditionName(conditionName string, collector *ErrorCollector, lineIndex *int, meta *Meta) bool { +func ValidateConditionName(conditionName string, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { if !validateFieldValue(conditionNameRule, conditionName) { - collector.RaiseInvalidConditionName(conditionName, conditionNameRule, lineIndex, meta) + errs.Add(newInvalidConditionNameError(lines, conditionName, conditionNameRule, lineIndex, meta)) return false } @@ -197,22 +197,22 @@ func GetConditionLineNumber(conditionName string, lines []string, skipIndex *int // 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, +func ValidateNameRules(errs *ValidationErrors, typeName string, relationNames []string, typeLineIndex *int, meta *Meta, lines []string) { // Validate type name - ValidateTypeName(typeName, collector, typeLineIndex, meta) + ValidateTypeName(typeName, errs, lines, typeLineIndex, meta) // Validate relation names for _, relationName := range relationNames { relationLineIndex := GetRelationLineNumber(relationName, lines, nil) - ValidateRelationName(relationName, typeName, collector, relationLineIndex, meta) + ValidateRelationName(relationName, typeName, errs, lines, relationLineIndex, meta) } } // 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) { +func ValidateNames(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } @@ -228,11 +228,11 @@ func ValidateNames(collector *ErrorCollector, model *openfgav1.AuthorizationMode } typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - ValidateTypeName(typeName, collector, typeLineIndex, meta) + ValidateTypeName(typeName, errs, lines, typeLineIndex, meta) for _, relationName := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { relationLineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - ValidateRelationName(relationName, typeName, collector, relationLineIndex, meta) + ValidateRelationName(relationName, typeName, errs, lines, relationLineIndex, meta) } } @@ -244,6 +244,6 @@ func ValidateNames(collector *ErrorCollector, model *openfgav1.AuthorizationMode File: condition.GetMetadata().GetSourceInfo().GetFile(), Module: condition.GetMetadata().GetModule(), } - ValidateConditionName(conditionName, collector, conditionLineIndex, meta) + ValidateConditionName(conditionName, errs, lines, conditionLineIndex, meta) } } diff --git a/pkg/go/validation/name_validation_test.go b/pkg/go/validation/name_validation_test.go index 4229ded9..a8f9d6b5 100644 --- a/pkg/go/validation/name_validation_test.go +++ b/pkg/go/validation/name_validation_test.go @@ -121,11 +121,11 @@ func TestValidateTypeName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) lineIndex := 5 meta := &Meta{File: "test.fga", Module: "test"} - result := ValidateTypeName(tt.typeName, collector, &lineIndex, meta) + result := ValidateTypeName(tt.typeName, collector, nil, &lineIndex, meta) assert.Equal(t, tt.expectedValid, result) @@ -207,11 +207,11 @@ func TestValidateRelationName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) lineIndex := 8 meta := &Meta{File: "test.fga", Module: "test"} - result := ValidateRelationName(tt.relationName, tt.typeName, collector, &lineIndex, meta) + result := ValidateRelationName(tt.relationName, tt.typeName, collector, nil, &lineIndex, meta) assert.Equal(t, tt.expectedValid, result) @@ -272,11 +272,11 @@ func TestValidateConditionName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) lineIndex := 10 meta := &Meta{File: "test.fga", Module: "test"} - result := ValidateConditionName(tt.conditionName, collector, &lineIndex, meta) + result := ValidateConditionName(tt.conditionName, collector, nil, &lineIndex, meta) assert.Equal(t, tt.expectedValid, result) @@ -589,7 +589,7 @@ func TestValidateNameRules(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) + collector := NewValidationErrors(nil) typeLineIndex := GetTypeLineNumber(tt.typeName, tt.lines, nil) meta := &Meta{File: "test.fga", Module: "test"} @@ -607,12 +607,12 @@ func TestNameValidationIntegration(t *testing.T) { validNames := []string{"document", "user", "group", "viewer", "editor", "admin"} for _, name := range validNames { - collector := NewErrorCollector(nil) - typeValid := ValidateTypeName(name, collector, nil, nil) + collector := NewValidationErrors(nil) + typeValid := ValidateTypeName(name, collector, nil, nil, nil) assert.True(t, typeValid, "Expected %s to be valid type name", name) - collector = NewErrorCollector(nil) - relationValid := ValidateRelationName(name, "parent_type", collector, nil, nil) + collector = NewValidationErrors(nil) + relationValid := ValidateRelationName(name, "parent_type", collector, nil, nil, nil) assert.True(t, relationValid, "Expected %s to be valid relation name", name) } }) @@ -621,12 +621,12 @@ func TestNameValidationIntegration(t *testing.T) { reservedKeywords := []string{"this", "self"} for _, keyword := range reservedKeywords { - collector := NewErrorCollector(nil) - typeValid := ValidateTypeName(keyword, collector, nil, nil) + collector := NewValidationErrors(nil) + typeValid := ValidateTypeName(keyword, collector, nil, nil, nil) assert.False(t, typeValid, "Expected %s to be invalid type name", keyword) - collector = NewErrorCollector(nil) - relationValid := ValidateRelationName(keyword, "parent_type", collector, nil, nil) + collector = NewValidationErrors(nil) + relationValid := ValidateRelationName(keyword, "parent_type", collector, nil, nil, nil) assert.False(t, relationValid, "Expected %s to be invalid relation name", keyword) } }) diff --git a/pkg/go/validation/schema_validation.go b/pkg/go/validation/schema_validation.go index 3258cde7..b1a133d8 100644 --- a/pkg/go/validation/schema_validation.go +++ b/pkg/go/validation/schema_validation.go @@ -47,14 +47,14 @@ func GetSchemaLineNumber(schemaVersion string, lines []string) *int { } // ValidateSchemaVersion validates the schema version of an authorization model. -func ValidateSchemaVersion(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateSchemaVersion(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } schemaVersion := model.GetSchemaVersion() if schemaVersion == "" { lineIndex := 0 - collector.RaiseSchemaVersionRequired("", &lineIndex) + errs.Add(newSchemaVersionRequiredError(lines, &lineIndex)) return } switch schemaVersion { @@ -62,10 +62,10 @@ func ValidateSchemaVersion(collector *ErrorCollector, model *openfgav1.Authoriza // Supported — nothing to report. case "1.0": // Recognized but retired. - collector.RaiseSchemaVersionUnsupported(schemaVersion, GetSchemaLineNumber(schemaVersion, lines)) + errs.Add(newSchemaVersionUnsupportedError(lines, schemaVersion, GetSchemaLineNumber(schemaVersion, lines))) default: // Never a valid schema version. - collector.RaiseInvalidSchemaVersion(schemaVersion, GetSchemaLineNumber(schemaVersion, lines)) + errs.Add(newInvalidSchemaVersionError(lines, schemaVersion, GetSchemaLineNumber(schemaVersion, lines))) } } @@ -75,19 +75,19 @@ func ValidateSchemaVersion(collector *ErrorCollector, model *openfgav1.Authoriza // It reports the files, and each file's modules, in the order they were collected // from the model, which is the order the reference reports them in and the order the // shared corpus expects. -func ValidateMultipleModulesInFile(collector *ErrorCollector, files []FileInfo) { +func ValidateMultipleModulesInFile(errs *ValidationErrors, files []FileInfo) { for _, file := range files { if len(file.Modules) <= 1 { continue } - collector.RaiseMultipleModulesInSingleFile(file.Path, file.Modules) + errs.Add(newMultipleModulesInSingleFileError(file.Path, file.Modules)) } } // ValidateBasicModelStructure performs basic model structure validation. -func ValidateBasicModelStructure(collector *ErrorCollector, model *openfgav1.AuthorizationModel, +func ValidateBasicModelStructure(errs *ValidationErrors, model *openfgav1.AuthorizationModel, files []FileInfo, lines []string) { - ValidateSchemaVersion(collector, model, lines) - ValidateMultipleModulesInFile(collector, files) + ValidateSchemaVersion(errs, model, lines) + ValidateMultipleModulesInFile(errs, files) } diff --git a/pkg/go/validation/schema_validation_test.go b/pkg/go/validation/schema_validation_test.go index 46f93994..08e326a9 100644 --- a/pkg/go/validation/schema_validation_test.go +++ b/pkg/go/validation/schema_validation_test.go @@ -203,7 +203,7 @@ func TestValidateSchemaVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) + collector := NewValidationErrors(nil) ValidateSchemaVersion(collector, tt.model, tt.lines) @@ -278,7 +278,7 @@ func TestValidateMultipleModulesInFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateMultipleModulesInFile(collector, tt.files) @@ -348,7 +348,7 @@ func TestValidateBasicModelStructure(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) + collector := NewValidationErrors(nil) ValidateBasicModelStructure(collector, tt.model, tt.files, tt.lines) @@ -374,7 +374,7 @@ func TestSchemaVersionConstants(t *testing.T) { } func TestSchemaVersionValidation(t *testing.T) { - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) // Test valid schema version validModel := &openfgav1.AuthorizationModel{ @@ -384,7 +384,7 @@ func TestSchemaVersionValidation(t *testing.T) { assert.Empty(t, collector.AllFindings()) // Test invalid schema version - collector = NewErrorCollector(nil) + collector = NewValidationErrors(nil) invalidModel := &openfgav1.AuthorizationModel{ SchemaVersion: "2.0", } diff --git a/pkg/go/validation/semantic_validation.go b/pkg/go/validation/semantic_validation.go index 96419b96..c213a768 100644 --- a/pkg/go/validation/semantic_validation.go +++ b/pkg/go/validation/semantic_validation.go @@ -86,14 +86,14 @@ func (sv *SemanticValidator) GetDirectlyAssignableTypes(typeName, relationName s } // ValidateRelationReferences validates that all relation references in the model are valid. -func ValidateRelationReferences(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateRelationReferences(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } - validateRelationReferences(collector, NewSemanticValidator(model), lines) + validateRelationReferences(errs, NewSemanticValidator(model), lines) } -func validateRelationReferences(collector *ErrorCollector, validator *SemanticValidator, lines []string) { +func validateRelationReferences(errs *ValidationErrors, validator *SemanticValidator, lines []string) { model := validator.model if model == nil { return @@ -120,20 +120,20 @@ func validateRelationReferences(collector *ErrorCollector, validator *SemanticVa if meta := typeDef.GetMetadata(); meta != nil { relationsMetadata := meta.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { - validateTypeRestrictions(collector, validator, typeName, relationName, + validateTypeRestrictions(errs, validator, typeName, relationName, relationsMetadata[relationName], typeLineIndex, lines) } } relations := typeDef.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relations)) { - validateUsersetReferences(collector, validator, typeName, relationName, + validateUsersetReferences(errs, validator, typeName, relationName, relations[relationName], typeLineIndex, lines) } } } -func validateTypeRestrictions(collector *ErrorCollector, validator *SemanticValidator, +func validateTypeRestrictions(errs *ValidationErrors, validator *SemanticValidator, typeName, relationName string, relationMetadata *openfgav1.RelationMetadata, typeLineIndex *int, lines []string) { if relationMetadata == nil { return @@ -150,7 +150,7 @@ func validateTypeRestrictions(collector *ErrorCollector, validator *SemanticVali // 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) + errs.Add(newInvalidTypeError(lines, restrictedType, meta, lineIndex)) continue } // A type#relation restriction whose relation doesn't exist on that type: @@ -160,13 +160,13 @@ func validateTypeRestrictions(collector *ErrorCollector, validator *SemanticVali lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) symbol := restrictedType + "#" + rel // offendingType is the enclosing type the restriction was written in. - collector.RaiseInvalidTypeRelation(symbol, restrictedType, relationName, rel, typeName, lineIndex, meta) + errs.Add(newInvalidTypeRelationError(lines, symbol, restrictedType, relationName, rel, typeName, lineIndex, meta)) } } } } -func validateUsersetReferences(collector *ErrorCollector, validator *SemanticValidator, +func validateUsersetReferences(errs *ValidationErrors, validator *SemanticValidator, typeName, relationName string, userset *openfgav1.Userset, typeLineIndex *int, lines []string) { if userset == nil { return @@ -183,28 +183,28 @@ func validateUsersetReferences(collector *ErrorCollector, validator *SemanticVal if targetRelation := cu.GetRelation(); targetRelation != "" { if !validator.RelationDefined(typeName, targetRelation) { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseInvalidRelationError(targetRelation, typeName, relationName, lineIndex, meta) + errs.Add(newInvalidRelationError(lines, targetRelation, typeName, relationName, lineIndex, meta)) } } } if ttu := userset.GetTupleToUserset(); ttu != nil { - validateTupleToUsersetReferences(collector, validator, typeName, relationName, ttu, meta, typeLineIndex, lines) + validateTupleToUsersetReferences(errs, validator, typeName, relationName, ttu, meta, typeLineIndex, lines) } if union := userset.GetUnion(); union != nil { for _, child := range union.GetChild() { - validateUsersetReferences(collector, validator, typeName, relationName, child, typeLineIndex, lines) + validateUsersetReferences(errs, validator, typeName, relationName, child, typeLineIndex, lines) } } if intersection := userset.GetIntersection(); intersection != nil { for _, child := range intersection.GetChild() { - validateUsersetReferences(collector, validator, typeName, relationName, child, typeLineIndex, lines) + validateUsersetReferences(errs, validator, typeName, relationName, child, typeLineIndex, lines) } } if diff := userset.GetDifference(); diff != nil { - validateUsersetReferences(collector, validator, typeName, relationName, diff.GetBase(), typeLineIndex, lines) - validateUsersetReferences(collector, validator, typeName, relationName, diff.GetSubtract(), typeLineIndex, lines) + validateUsersetReferences(errs, validator, typeName, relationName, diff.GetBase(), typeLineIndex, lines) + validateUsersetReferences(errs, validator, typeName, relationName, diff.GetSubtract(), typeLineIndex, lines) } } @@ -215,7 +215,7 @@ func validateUsersetReferences(collector *ErrorCollector, validator *SemanticVal // 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, +func validateTupleToUsersetReferences(errs *ValidationErrors, validator *SemanticValidator, typeName, relationName string, ttu *openfgav1.TupleToUserset, meta *Meta, typeLineIndex *int, lines []string) { fromRelation := ttu.GetTupleset().GetRelation() targetRelation := ttu.GetComputedUserset().GetRelation() @@ -227,14 +227,14 @@ func validateTupleToUsersetReferences(collector *ErrorCollector, validator *Sema // 1. The `from` relation must exist on the current type. if !validator.RelationDefined(typeName, fromRelation) { - collector.RaiseInvalidTypeRelation(symbol, typeName, relationName, fromRelation, typeName, lineIndex, meta) + errs.Add(newInvalidTypeRelationError(lines, symbol, typeName, relationName, fromRelation, typeName, lineIndex, meta)) return } // 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) + errs.Add(newTupleUsersetRequiresDirectError(lines, fromRelation, typeName, relationName, meta, lineIndex)) return } @@ -246,7 +246,7 @@ func validateTupleToUsersetReferences(collector *ErrorCollector, validator *Sema decodedType := tr.GetType() if tr.GetWildcard() != nil || tr.GetRelation() != "" { // A wildcard or type#relation cannot be used as a tupleset target. - collector.RaiseTupleUsersetRequiresDirect(fromRelation, typeName, relationName, meta, lineIndex) + errs.Add(newTupleUsersetRequiresDirectError(lines, fromRelation, typeName, relationName, meta, lineIndex)) continue } if !validator.TypeDefined(decodedType) || !validator.RelationDefined(decodedType, targetRelation) { @@ -256,7 +256,7 @@ func validateTupleToUsersetReferences(collector *ErrorCollector, validator *Sema // 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) + errs.Add(newInvalidRelationOnTuplesetError(lines, symbol, tr.GetType(), typeName, relationName, targetRelation, fromRelation, lineIndex, meta)) } } } diff --git a/pkg/go/validation/semantic_validation_test.go b/pkg/go/validation/semantic_validation_test.go index e04310d8..08926783 100644 --- a/pkg/go/validation/semantic_validation_test.go +++ b/pkg/go/validation/semantic_validation_test.go @@ -139,7 +139,7 @@ func TestValidateRelationReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateRelationReferences(collector, model, nil) errors := collector.AllFindings() @@ -164,7 +164,7 @@ func TestValidateRelationReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateRelationReferences(collector, model, nil) errors := collector.AllFindings() @@ -194,7 +194,7 @@ func TestValidateRelationReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateRelationReferences(collector, model, nil) errors := collector.AllFindings() @@ -234,7 +234,7 @@ func TestValidateRelationReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateRelationReferences(collector, model, nil) errors := collector.AllFindings() @@ -287,7 +287,7 @@ func TestValidateRelationReferences(t *testing.T) { }, } - collector := NewErrorCollector(nil) + collector := NewValidationErrors(nil) ValidateRelationReferences(collector, model, nil) errors := collector.AllFindings() diff --git a/pkg/go/validation/severity_predicates_test.go b/pkg/go/validation/severity_predicates_test.go index 9a3f2fa4..1dbd9bc9 100644 --- a/pkg/go/validation/severity_predicates_test.go +++ b/pkg/go/validation/severity_predicates_test.go @@ -223,20 +223,20 @@ func TestBlockingFindingMakesModelInvalid(t *testing.T) { func TestCascadeGateIgnoresNonBlockingFindings(t *testing.T) { t.Parallel() - collector := NewErrorCollector(nil) - collector.errors = append(collector.errors, + collector := NewValidationErrors(nil) + collector.Errors = append(collector.Errors, finding(fgaerrors.SeverityAdvisory, "advisory"), finding(fgaerrors.SeverityWarning, "warning"), ) require.False(t, collector.HasErrors(), - "a collector holding only non-blocking findings must not close the cascade gate") + "a collection holding only non-blocking findings must not close the cascade gate") assert.Equal(t, 0, collector.Count()) assert.Equal(t, 2, collector.CountAll()) assert.Len(t, collector.AllFindings(), 2, - "the collector is the raw record and filters nothing") + "the collection is the raw record and filters nothing") - collector.errors = append(collector.errors, finding(fgaerrors.SeverityError, "real error")) + collector.Errors = append(collector.Errors, finding(fgaerrors.SeverityError, "real error")) assert.True(t, collector.HasErrors(), "a blocking finding must close the gate") } @@ -245,8 +245,8 @@ func TestCascadeGateIgnoresNonBlockingFindings(t *testing.T) { func TestSummarySplitsBySeverity(t *testing.T) { t.Parallel() - engine := &ValidationEngine{collector: NewErrorCollector(nil)} - engine.collector.errors = append(engine.collector.errors, + engine := &ValidationEngine{errs: NewValidationErrors(nil)} + engine.errs.Errors = append(engine.errs.Errors, finding(fgaerrors.SeverityError, "error"), finding(fgaerrors.SeverityWarning, "warning"), finding(fgaerrors.SeverityAdvisory, "advisory"), diff --git a/pkg/go/validation/validation_engine.go b/pkg/go/validation/validation_engine.go index 2743138c..d67759f3 100644 --- a/pkg/go/validation/validation_engine.go +++ b/pkg/go/validation/validation_engine.go @@ -10,9 +10,9 @@ import ( // ValidationEngine is the main entry point for all validation operations. type ValidationEngine struct { - model *openfgav1.AuthorizationModel - lines []string - collector *ErrorCollector + model *openfgav1.AuthorizationModel + lines []string + errs *ValidationErrors // 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 @@ -34,8 +34,7 @@ func DefaultEngineOptions() *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} + ve := &ValidationEngine{model: model, lines: lines, errs: NewValidationErrors(nil)} if model != nil { ve.semantic = NewSemanticValidator(model) ve.condition = NewConditionValidator(model) @@ -80,8 +79,8 @@ func (ve *ValidationEngine) RunAllValidations(options *EngineOptions) *Validatio } // Schema and name validation run first and unconditionally. - ValidateSchemaVersion(ve.collector, ve.model, ve.lines) - ValidateNames(ve.collector, ve.model, ve.lines) + ValidateSchemaVersion(ve.errs, ve.model, ve.lines) + ValidateNames(ve.errs, ve.model, ve.lines) // Relation-reference validation always runs. The phases that follow are // gated on there being no blocking error yet: a model with bad references or @@ -93,38 +92,38 @@ func (ve *ValidationEngine) RunAllValidations(options *EngineOptions) *Validatio // The gate counts blocking findings only, so a warning or advisory does not stop // the later passes from finding an error that would invalidate the model. if !options.SkipSemanticValidation { - validateRelationReferences(ve.collector, ve.semantic, ve.lines) + validateRelationReferences(ve.errs, ve.semantic, ve.lines) } - if !ve.collector.HasErrors() { - ValidateDuplicates(ve.collector, ve.model, ve.lines) + if !ve.errs.HasErrors() { + ValidateDuplicates(ve.errs, ve.model, ve.lines) } - if !ve.collector.HasErrors() { + if !ve.errs.HasErrors() { if !options.SkipSemanticValidation { - validateCyclesAndEntryPoints(ve.collector, ve.semantic, ve.lines) - validateTupleToUsersetRequirements(ve.collector, ve.semantic, ve.lines) + validateCyclesAndEntryPoints(ve.errs, ve.semantic, ve.lines) + validateTupleToUsersetRequirements(ve.errs, ve.semantic, ve.lines) } if !options.SkipComplexOperationValidation { - validateComplexOperations(ve.collector, ve.semantic, ve.lines) + validateComplexOperations(ve.errs, ve.semantic, ve.lines) } if !options.SkipWildcardValidation { - validateWildcardUsage(ve.collector, ve.semantic, ve.lines) + validateWildcardUsage(ve.errs, 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 { - ValidateMultiFileConsistency(ve.collector, ve.model, ve.lines) + ValidateMultiFileConsistency(ve.errs, ve.model, ve.lines) } if !options.SkipConditionValidation { - validateConditionReferences(ve.collector, ve.condition, ve.lines) - ValidateConditionConsistency(ve.collector, ve.model, ve.lines) - validateUnusedConditions(ve.collector, ve.condition, ve.lines) + validateConditionReferences(ve.errs, ve.condition, ve.lines) + ValidateConditionConsistency(ve.errs, ve.model, ve.lines) + validateUnusedConditions(ve.errs, ve.condition, ve.lines) } - return NewValidationErrors(ve.collector.AllFindings()) + return ve.errs } // ValidateModel is ValidateDSL with the default options, which skip no phase. @@ -138,10 +137,10 @@ func ValidateModelJSON(model *openfgav1.AuthorizationModel) error { } func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { - errors := ve.collector.AllFindings() + errors := ve.errs.AllFindings() summary := ValidationSummary{ - TotalErrors: ve.collector.Count(), - TotalFindings: ve.collector.CountAll(), + TotalErrors: ve.errs.Count(), + TotalFindings: ve.errs.CountAll(), ErrorsByType: make(map[ValidationErrorType]int), ErrorsByFile: make(map[string]int), FindingsBySeverity: make(map[fgaerrors.Severity]int), @@ -149,7 +148,7 @@ func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { } for _, err := range errors { if err == nil || err.Metadata == nil { - // Metadata is always set by the collector, but a directly-constructed + // The constructors always set metadata, but a directly-constructed // error (e.g. in a consumer or test) could omit it; don't panic. continue } @@ -217,7 +216,7 @@ func (vr *ValidationReport) HasCriticalErrors() bool { return vr.Summary.HasCrit func (vr *ValidationReport) GetErrorsByType(errorType ValidationErrorType) []*ValidationError { var matchingErrors []*ValidationError for _, err := range vr.ValidationErrors.AllFindings() { - // The collector always sets metadata, but a directly-constructed finding + // The constructors always set metadata, but a directly-constructed finding // need not have, and a code is only readable off metadata. if err == nil || err.Metadata == nil { continue diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go index 6eed62c7..b341bc21 100644 --- a/pkg/go/validation/wildcard_validation.go +++ b/pkg/go/validation/wildcard_validation.go @@ -1,24 +1,21 @@ package validation import ( - "fmt" "maps" "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" - - fgaerrors "github.com/openfga/language/pkg/go/errors" ) // ValidateWildcardUsage validates wildcard relation usage rules. -func ValidateWildcardUsage(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateWildcardUsage(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } - validateWildcardUsage(collector, NewSemanticValidator(model), lines) + validateWildcardUsage(errs, NewSemanticValidator(model), lines) } -func validateWildcardUsage(collector *ErrorCollector, validator *SemanticValidator, lines []string) { +func validateWildcardUsage(errs *ValidationErrors, validator *SemanticValidator, lines []string) { model := validator.model if model == nil { return @@ -29,13 +26,13 @@ func validateWildcardUsage(collector *ErrorCollector, validator *SemanticValidat } relationsMetadata := typeDef.GetMetadata().GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { - validateWildcardInRelation(collector, validator, typeDef.GetType(), relationName, + validateWildcardInRelation(errs, validator, typeDef.GetType(), relationName, relationsMetadata[relationName], lines) } } } -func validateWildcardInRelation(collector *ErrorCollector, validator *SemanticValidator, +func validateWildcardInRelation(errs *ValidationErrors, validator *SemanticValidator, typeName, relationName string, relationMetadata *openfgav1.RelationMetadata, lines []string) { if relationMetadata == nil { return @@ -52,34 +49,34 @@ func validateWildcardInRelation(collector *ErrorCollector, validator *SemanticVa continue } if typeRestriction.GetWildcard() != nil { - validateWildcardRestriction(collector, validator, typeRestriction, relationName, typeName, meta, lines, typeLineIndex) + validateWildcardRestriction(errs, 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) + errs.Add(newInvalidWildcardUsageError(lines, typeRestriction.GetType(), relationName, typeName, + "wildcard cannot be used with specific relation", meta, lineIndex)) } } } } -func validateWildcardRestriction(collector *ErrorCollector, validator *SemanticValidator, +func validateWildcardRestriction(errs *ValidationErrors, 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) + errs.Add(newUndefinedTypeError(lines, typeRestriction.GetType(), relationName, typeName, meta, lineIndex)) } } // ValidateTupleToUsersetRequirements validates tuple-to-userset usage requirements. -func ValidateTupleToUsersetRequirements(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { +func ValidateTupleToUsersetRequirements(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { if model == nil { return } - validateTupleToUsersetRequirements(collector, NewSemanticValidator(model), lines) + validateTupleToUsersetRequirements(errs, NewSemanticValidator(model), lines) } -func validateTupleToUsersetRequirements(collector *ErrorCollector, validator *SemanticValidator, lines []string) { +func validateTupleToUsersetRequirements(errs *ValidationErrors, validator *SemanticValidator, lines []string) { model := validator.model if model == nil { return @@ -87,13 +84,13 @@ func validateTupleToUsersetRequirements(collector *ErrorCollector, validator *Se for _, typeDef := range model.GetTypeDefinitions() { relations := typeDef.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relations)) { - validateTupleToUsersetInUserset(collector, validator, typeDef.GetType(), relationName, + validateTupleToUsersetInUserset(errs, validator, typeDef.GetType(), relationName, relations[relationName], lines) } } } -func validateTupleToUsersetInUserset(collector *ErrorCollector, validator *SemanticValidator, +func validateTupleToUsersetInUserset(errs *ValidationErrors, validator *SemanticValidator, typeName, relationName string, userset *openfgav1.Userset, lines []string) { if userset == nil { return @@ -105,25 +102,25 @@ func validateTupleToUsersetInUserset(collector *ErrorCollector, validator *Seman File: typeDef.GetMetadata().GetSourceInfo().GetFile(), Module: typeDef.GetMetadata().GetModule(), } - validateTupleToUsersetOperation(collector, validator, typeName, relationName, ttu, meta, lines) + validateTupleToUsersetOperation(errs, validator, typeName, relationName, ttu, meta, lines) } if union := userset.GetUnion(); union != nil { for _, child := range union.GetChild() { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, child, lines) + validateTupleToUsersetInUserset(errs, validator, typeName, relationName, child, lines) } } if intersection := userset.GetIntersection(); intersection != nil { for _, child := range intersection.GetChild() { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, child, lines) + validateTupleToUsersetInUserset(errs, validator, typeName, relationName, child, lines) } } if diff := userset.GetDifference(); diff != nil { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, diff.GetBase(), lines) - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, diff.GetSubtract(), lines) + validateTupleToUsersetInUserset(errs, validator, typeName, relationName, diff.GetBase(), lines) + validateTupleToUsersetInUserset(errs, validator, typeName, relationName, diff.GetSubtract(), lines) } } -func validateTupleToUsersetOperation(collector *ErrorCollector, validator *SemanticValidator, +func validateTupleToUsersetOperation(errs *ValidationErrors, validator *SemanticValidator, typeName, relationName string, ttu *openfgav1.TupleToUserset, meta *Meta, lines []string) { tuplesetRelation := ttu.GetTupleset().GetRelation() if tuplesetRelation == "" { @@ -135,10 +132,10 @@ func validateTupleToUsersetOperation(collector *ErrorCollector, validator *Seman if !validator.RelationDefined(typeName, tuplesetRelation) { return } - validateTuplesetDirectAssignment(collector, validator, typeName, tuplesetRelation, relationName, meta, lines) + validateTuplesetDirectAssignment(errs, validator, typeName, tuplesetRelation, relationName, meta, lines) } -func validateTuplesetDirectAssignment(collector *ErrorCollector, validator *SemanticValidator, +func validateTuplesetDirectAssignment(errs *ValidationErrors, validator *SemanticValidator, typeName, tuplesetRelation, parentRelation string, meta *Meta, lines []string) { typeDef := validator.GetTypeDefinition(typeName) if typeDef == nil { @@ -148,26 +145,8 @@ func validateTuplesetDirectAssignment(collector *ErrorCollector, validator *Sema if rm, ok := metaProto.GetRelations()[tuplesetRelation]; ok { if len(rm.GetDirectlyRelatedUserTypes()) == 0 { lineIndex := GetRelationLineNumber(parentRelation, lines, nil) - collector.RaiseTuplesetNotDirect(tuplesetRelation, typeName, parentRelation, meta, lineIndex) + errs.Add(newTuplesetNotDirectError(lines, tuplesetRelation, typeName, parentRelation, meta, lineIndex)) } } } } - -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) - // The wildcard is written in a relation of parentTypeName; typeName is the - // restriction it appears in, which the symbol already records. - c.addScopedError(message, InvalidWildcardError, typeName, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: parentTypeName, Relation: relationName}, - }) -} - -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.addScopedError(message, TuplesetNotDirect, tuplesetRelation, lineIndex, meta, nil, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: tuplesetRelation}, - }) -} From 799e3fe7ff76d07513f51b6cee9fce549965bcbb Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Fri, 28 Aug 2026 22:46:19 +0530 Subject: [PATCH 5/8] refactor(pkg/go): give the wide error constructors struct arguments The three constructors that took four or more same-typed string arguments now take a named-field struct, so a transposed argument is a compile error rather than a silently wrong message or scope. The remaining constructors put meta before lineIndex, the order the majority already used, so the two trailing pointers no longer differ by constructor. --- pkg/go/validation/error_builders.go | 84 ++++++++++++++++-------- pkg/go/validation/error_builders_test.go | 12 ++-- pkg/go/validation/name_validation.go | 10 +-- pkg/go/validation/semantic_validation.go | 33 ++++++++-- pkg/go/validation/wildcard_validation.go | 10 ++- 5 files changed, 105 insertions(+), 44 deletions(-) diff --git a/pkg/go/validation/error_builders.go b/pkg/go/validation/error_builders.go index f09d3152..6347014f 100644 --- a/pkg/go/validation/error_builders.go +++ b/pkg/go/validation/error_builders.go @@ -10,7 +10,7 @@ import ( // newInvalidNameError reports a name that breaks a naming rule. A nil typeName means the // offending name is a type rather than a relation on one, which changes the message and // the scope. -func newInvalidNameError(lines []string, symbol, clause string, typeName *string, lineIndex *int, meta *Meta) *ValidationError { +func newInvalidNameError(lines []string, symbol, clause string, typeName *string, meta *Meta, lineIndex *int) *ValidationError { var message string errorScope := scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}} @@ -27,20 +27,20 @@ func newInvalidNameError(lines []string, symbol, clause string, typeName *string // newInvalidConditionNameError reports a condition name that breaks a naming rule, // scoped to the condition rather than a type or relation. -func newInvalidConditionNameError(lines []string, symbol, clause string, lineIndex *int, meta *Meta) *ValidationError { +func newInvalidConditionNameError(lines []string, symbol, clause string, meta *Meta, lineIndex *int) *ValidationError { message := fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", symbol, clause) line, column := resolvePosition(lines, symbol, lineIndex, nil) return newValidationError(message, InvalidName, symbol, line, column, scope{part: &fgaerrors.ErrCondition{Condition: symbol}}, meta) } // newReservedTypeNameError reports a type named with a reserved keyword. -func newReservedTypeNameError(lines []string, symbol string, lineIndex *int, meta *Meta) *ValidationError { +func newReservedTypeNameError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { line, column := resolvePosition(lines, symbol, lineIndex, nil) return newValidationError("a type cannot be named 'self' or 'this'.", ReservedTypeKeywords, symbol, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}}, meta) } // newReservedRelationNameError reports a relation named with a reserved keyword. -func newReservedRelationNameError(lines []string, symbol, typeName string, lineIndex *int, meta *Meta) *ValidationError { +func newReservedRelationNameError(lines []string, symbol, typeName string, meta *Meta, lineIndex *int) *ValidationError { line, column := resolvePosition(lines, symbol, lineIndex, nil) return newValidationError("a relation cannot be named 'self' or 'this'.", ReservedRelationKeywords, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) } @@ -122,27 +122,48 @@ func newNoEntryPointError(lines []string, symbol, typeName string, meta *Meta, l return newValidationError(message, RelationNoEntrypoint, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) } +// invalidRelationOnTuplesetArgs names the parts of an invalid-relation-on-tupleset +// finding, which would otherwise be six same-typed positional arguments. +type invalidRelationOnTuplesetArgs struct { + symbol string + typeName string + typeDef string + relationName string + offendingRelation string + parent string + meta *Meta + lineIndex *int +} + // newInvalidRelationOnTuplesetError reports a tupleset relation whose target does not // exist on the referenced type. -func newInvalidRelationOnTuplesetError(lines []string, symbol, typeName, typeDef, relationName, - offendingRelation, parent string, lineIndex *int, meta *Meta) *ValidationError { +func newInvalidRelationOnTuplesetError(lines []string, a invalidRelationOnTuplesetArgs) *ValidationError { 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) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, InvalidRelationOnTupleset, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeDef, Relation: relationName}}, meta) + a.offendingRelation, a.typeDef, a.offendingRelation, a.parent, a.typeName) + line, column := resolvePosition(lines, a.symbol, a.lineIndex, nil) + return newValidationError(message, InvalidRelationOnTupleset, a.symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: a.typeDef, Relation: a.relationName}}, a.meta) +} + +// invalidTypeRelationArgs names the parts of an invalid-relation-type finding. Its +// offendingType is the enclosing type the reference was written in, kept as metadata. +type invalidTypeRelationArgs struct { + symbol string + typeName string + relationName string + offendingRelation string + offendingType string + meta *Meta + lineIndex *int } // newInvalidTypeRelationError reports a relation reference that is not valid for a type. -// Its offendingType argument is the enclosing type the reference was written in, kept as -// metadata. -func newInvalidTypeRelationError(lines []string, symbol, typeName, relationName, offendingRelation, - offendingType string, lineIndex *int, meta *Meta) *ValidationError { - message := fmt.Sprintf("`%s` is not a valid relation for `%s`.", offendingRelation, typeName) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, InvalidRelationType, symbol, line, column, scope{ - part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}, - offendingType: offendingType, - }, meta) +func newInvalidTypeRelationError(lines []string, a invalidTypeRelationArgs) *ValidationError { + message := fmt.Sprintf("`%s` is not a valid relation for `%s`.", a.offendingRelation, a.typeName) + line, column := resolvePosition(lines, a.symbol, a.lineIndex, nil) + return newValidationError(message, InvalidRelationType, a.symbol, line, column, scope{ + part: &fgaerrors.ErrRelation{ObjectType: a.typeName, Relation: a.relationName}, + offendingType: a.offendingType, + }, a.meta) } // newInvalidTypeError reports an invalid type in an assignable-types list. Its column is @@ -183,7 +204,7 @@ func newAssignableTypeWildcardRelationError(lines []string, symbol, typeName, re // newInvalidRelationError reports a rewrite that names a relation the type does not // define. The message names the missing relation only, as the reference's does. func newInvalidRelationError(lines []string, symbol, typeName, relation string, - lineIndex *int, meta *Meta) *ValidationError { + meta *Meta, lineIndex *int) *ValidationError { message := fmt.Sprintf("the relation `%s` does not exist.", symbol) line, column := resolvePosition(lines, symbol, lineIndex, nil) return newValidationError(message, MissingDefinition, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}}, meta) @@ -273,14 +294,23 @@ func newEmptyDifferenceError(lines []string, relationName, typeName, operation s return newValidationError(message, RelationNoEntrypoint, relationName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) } -// newInvalidWildcardUsageError reports a wildcard used where it is not allowed. The -// wildcard is written in a relation of parentTypeName; typeName is the restriction it -// appears in, which the symbol already records. -func newInvalidWildcardUsageError(lines []string, typeName, relationName, parentTypeName, reason string, meta *Meta, lineIndex *int) *ValidationError { +// invalidWildcardUsageArgs names the parts of an invalid-wildcard finding. The wildcard +// is written in a relation of parentTypeName; typeName is the restriction it appears in. +type invalidWildcardUsageArgs struct { + typeName string + relationName string + parentTypeName string + reason string + meta *Meta + lineIndex *int +} + +// newInvalidWildcardUsageError reports a wildcard used where it is not allowed. +func newInvalidWildcardUsageError(lines []string, a invalidWildcardUsageArgs) *ValidationError { message := fmt.Sprintf("Invalid wildcard usage for type '%s' in relation '%s' of type '%s': %s", - typeName, relationName, parentTypeName, reason) - line, column := resolvePosition(lines, typeName, lineIndex, nil) - return newValidationError(message, InvalidWildcardError, typeName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: parentTypeName, Relation: relationName}}, meta) + a.typeName, a.relationName, a.parentTypeName, a.reason) + line, column := resolvePosition(lines, a.typeName, a.lineIndex, nil) + return newValidationError(message, InvalidWildcardError, a.typeName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: a.parentTypeName, Relation: a.relationName}}, a.meta) } // newTuplesetNotDirectError reports a tupleset relation that does not allow direct diff --git a/pkg/go/validation/error_builders_test.go b/pkg/go/validation/error_builders_test.go index 02d4a9b9..6f903342 100644 --- a/pkg/go/validation/error_builders_test.go +++ b/pkg/go/validation/error_builders_test.go @@ -100,7 +100,7 @@ func TestNewInvalidNameError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := NewValidationErrors(nil) - errs.Add(newInvalidNameError(nil, tt.symbol, tt.clause, tt.typeName, tt.lineIndex, tt.meta)) + errs.Add(newInvalidNameError(nil, tt.symbol, tt.clause, tt.typeName, tt.meta, tt.lineIndex)) findings := errs.AllFindings() assert.Len(t, findings, 1) @@ -115,7 +115,7 @@ func TestNewInvalidConditionNameError(t *testing.T) { lineIndex := 5 meta := &Meta{File: "test.fga", Module: "test"} - err := newInvalidConditionNameError(nil, "bad name", "[a-zA-Z]+", &lineIndex, meta) + err := newInvalidConditionNameError(nil, "bad name", "[a-zA-Z]+", meta, &lineIndex) assert.Equal(t, "condition 'bad name' does not match naming rule: '[a-zA-Z]+'.", err.Message) assert.Equal(t, InvalidName, err.Metadata.ErrorType) @@ -133,7 +133,7 @@ func TestNewReservedTypeNameError(t *testing.T) { lineIndex := 5 meta := &Meta{File: "test.fga", Module: "test"} - err := newReservedTypeNameError(nil, "self", &lineIndex, meta) + err := newReservedTypeNameError(nil, "self", meta, &lineIndex) assert.Equal(t, "a type cannot be named 'self' or 'this'.", err.Message) assert.Equal(t, ReservedTypeKeywords, err.Metadata.ErrorType) @@ -145,7 +145,7 @@ func TestNewReservedRelationNameError(t *testing.T) { lineIndex := 3 meta := &Meta{File: "test.fga", Module: "test"} - err := newReservedRelationNameError(nil, "this", "document", &lineIndex, meta) + err := newReservedRelationNameError(nil, "this", "document", meta, &lineIndex) assert.Equal(t, "a relation cannot be named 'self' or 'this'.", err.Message) assert.Equal(t, ReservedRelationKeywords, err.Metadata.ErrorType) @@ -239,7 +239,7 @@ func TestNewInvalidRelationError(t *testing.T) { meta := &Meta{File: "test.fga", Module: "test"} lineIndex := 4 - err := newInvalidRelationError(nil, "unknown", "document", "relation", &lineIndex, meta) + err := newInvalidRelationError(nil, "unknown", "document", "relation", meta, &lineIndex) assert.Equal(t, "the relation `unknown` does not exist.", err.Message) assert.Equal(t, MissingDefinition, err.Metadata.ErrorType) @@ -315,7 +315,7 @@ func TestLineAndColumnResolution(t *testing.T) { } lineIndex := 4 - err := newInvalidNameError(lines, "viewer", "rule", nil, &lineIndex, nil) + err := newInvalidNameError(lines, "viewer", "rule", nil, nil, &lineIndex) // Check line information assert.NotNil(t, err.Line) diff --git a/pkg/go/validation/name_validation.go b/pkg/go/validation/name_validation.go index df104596..cd273a16 100644 --- a/pkg/go/validation/name_validation.go +++ b/pkg/go/validation/name_validation.go @@ -47,14 +47,14 @@ var ( func ValidateTypeName(typeName string, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { // First check if it's a reserved keyword if IsReservedTypeName(typeName) { - errs.Add(newReservedTypeNameError(lines, typeName, lineIndex, meta)) + errs.Add(newReservedTypeNameError(lines, typeName, meta, lineIndex)) 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) { - errs.Add(newInvalidNameError(lines, typeName, typeNameRule, nil, lineIndex, meta)) + errs.Add(newInvalidNameError(lines, typeName, typeNameRule, nil, meta, lineIndex)) return false } @@ -66,14 +66,14 @@ func ValidateTypeName(typeName string, errs *ValidationErrors, lines []string, l func ValidateRelationName(relationName, typeName string, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { // First check if it's a reserved keyword if IsReservedRelationName(relationName) { - errs.Add(newReservedRelationNameError(lines, relationName, typeName, lineIndex, meta)) + errs.Add(newReservedRelationNameError(lines, relationName, typeName, meta, lineIndex)) 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) { - errs.Add(newInvalidNameError(lines, relationName, relationNameRule, &typeName, lineIndex, meta)) + errs.Add(newInvalidNameError(lines, relationName, relationNameRule, &typeName, meta, lineIndex)) return false } @@ -83,7 +83,7 @@ func ValidateRelationName(relationName, typeName string, errs *ValidationErrors, // ValidateConditionName validates a condition name with regex pattern. func ValidateConditionName(conditionName string, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { if !validateFieldValue(conditionNameRule, conditionName) { - errs.Add(newInvalidConditionNameError(lines, conditionName, conditionNameRule, lineIndex, meta)) + errs.Add(newInvalidConditionNameError(lines, conditionName, conditionNameRule, meta, lineIndex)) return false } diff --git a/pkg/go/validation/semantic_validation.go b/pkg/go/validation/semantic_validation.go index c213a768..96c2bbc0 100644 --- a/pkg/go/validation/semantic_validation.go +++ b/pkg/go/validation/semantic_validation.go @@ -160,7 +160,15 @@ func validateTypeRestrictions(errs *ValidationErrors, validator *SemanticValidat lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) symbol := restrictedType + "#" + rel // offendingType is the enclosing type the restriction was written in. - errs.Add(newInvalidTypeRelationError(lines, symbol, restrictedType, relationName, rel, typeName, lineIndex, meta)) + errs.Add(newInvalidTypeRelationError(lines, invalidTypeRelationArgs{ + symbol: symbol, + typeName: restrictedType, + relationName: relationName, + offendingRelation: rel, + offendingType: typeName, + meta: meta, + lineIndex: lineIndex, + })) } } } @@ -183,7 +191,7 @@ func validateUsersetReferences(errs *ValidationErrors, validator *SemanticValida if targetRelation := cu.GetRelation(); targetRelation != "" { if !validator.RelationDefined(typeName, targetRelation) { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - errs.Add(newInvalidRelationError(lines, targetRelation, typeName, relationName, lineIndex, meta)) + errs.Add(newInvalidRelationError(lines, targetRelation, typeName, relationName, meta, lineIndex)) } } } @@ -227,7 +235,15 @@ func validateTupleToUsersetReferences(errs *ValidationErrors, validator *Semanti // 1. The `from` relation must exist on the current type. if !validator.RelationDefined(typeName, fromRelation) { - errs.Add(newInvalidTypeRelationError(lines, symbol, typeName, relationName, fromRelation, typeName, lineIndex, meta)) + errs.Add(newInvalidTypeRelationError(lines, invalidTypeRelationArgs{ + symbol: symbol, + typeName: typeName, + relationName: relationName, + offendingRelation: fromRelation, + offendingType: typeName, + meta: meta, + lineIndex: lineIndex, + })) return } @@ -256,7 +272,16 @@ func validateTupleToUsersetReferences(errs *ValidationErrors, validator *Semanti // If the target is missing on every assignable type, report it per type. if len(notValid) == len(fromTypes) { for _, tr := range notValid { - errs.Add(newInvalidRelationOnTuplesetError(lines, symbol, tr.GetType(), typeName, relationName, targetRelation, fromRelation, lineIndex, meta)) + errs.Add(newInvalidRelationOnTuplesetError(lines, invalidRelationOnTuplesetArgs{ + symbol: symbol, + typeName: tr.GetType(), + typeDef: typeName, + relationName: relationName, + offendingRelation: targetRelation, + parent: fromRelation, + meta: meta, + lineIndex: lineIndex, + })) } } } diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go index b341bc21..25d7e188 100644 --- a/pkg/go/validation/wildcard_validation.go +++ b/pkg/go/validation/wildcard_validation.go @@ -53,8 +53,14 @@ func validateWildcardInRelation(errs *ValidationErrors, validator *SemanticValid // wildcard and explicit relation together is invalid if typeRestriction.GetRelation() != "" { lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - errs.Add(newInvalidWildcardUsageError(lines, typeRestriction.GetType(), relationName, typeName, - "wildcard cannot be used with specific relation", meta, lineIndex)) + errs.Add(newInvalidWildcardUsageError(lines, invalidWildcardUsageArgs{ + typeName: typeRestriction.GetType(), + relationName: relationName, + parentTypeName: typeName, + reason: "wildcard cannot be used with specific relation", + meta: meta, + lineIndex: lineIndex, + })) } } } From 6ac3ca1ff2bc54fadb57d6b25cf2ed8979a1d8ce Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Tue, 1 Sep 2026 11:19:49 +0530 Subject: [PATCH 6/8] refactor(pkg/go)!: restructure validation around findings returned up the stack Replace the collector-and-bool flow with findings constructed at the point of failure and returned up the stack. - Finding is the wire shape (msg, line, column, file, metadata) with no custom JSON. Findings is both the collection and the error, following go/scanner.ErrorList; Err() is the only place it becomes an error. - Leaf validators take only what they check and return *Finding. The calling phase stamps position (at) and file/module provenance (in), because the caller is what holds the source text and proto metadata. - Kind, the wire errorType, is the single identity axis. The severity/ category/cause classification, its sentinels and table, the error collector, and the engine/options/summary/report types are removed. - Line lookups fold runs of inline whitespace (space, tab, form feed, the lexer WHITESPACE alphabet) so tab-separated declarations resolve. Matching only: columns still resolve against the raw line. Output is unchanged: both shared corpora pass with byte-identical message, errorType, symbol, line and column. BREAKING CHANGE: the exported validation API is now Finding, Findings, Kind, ValidateDSL and ValidateJSON. --- pkg/go/errors/doc.go | 16 - pkg/go/errors/example_test.go | 55 -- pkg/go/errors/model_error.go | 240 ------ pkg/go/errors/model_error_kind.go | 112 --- pkg/go/errors/model_error_test.go | 412 ----------- pkg/go/errors/sentinels.go | 99 --- pkg/go/errors/severity.go | 102 --- .../complex_operation_validation.go | 255 +++---- pkg/go/validation/condition_validation.go | 211 ++---- .../validation/condition_validation_test.go | 503 ------------- pkg/go/validation/context.go | 131 ---- pkg/go/validation/context_test.go | 339 --------- pkg/go/validation/criticality_test.go | 168 ----- pkg/go/validation/cycle_detection.go | 209 +++--- .../validation/cycle_detection_stress_test.go | 91 +-- pkg/go/validation/cycle_detection_test.go | 98 +-- pkg/go/validation/duplicate_detection.go | 242 +++--- pkg/go/validation/duplicate_detection_test.go | 641 ---------------- pkg/go/validation/error_builders.go | 323 -------- pkg/go/validation/error_builders_test.go | 399 ---------- pkg/go/validation/error_construction.go | 150 ---- pkg/go/validation/error_info.go | 166 ----- .../validation/error_info_integration_test.go | 353 --------- pkg/go/validation/error_info_test.go | 353 --------- pkg/go/validation/errors.go | 311 -------- pkg/go/validation/errors_test.go | 370 ---------- pkg/go/validation/findings.go | 161 ++++ pkg/go/validation/findings_test.go | 142 ++++ pkg/go/validation/index.go | 122 +++ pkg/go/validation/json_corpus_test.go | 7 +- pkg/go/validation/keywords.go | 29 - pkg/go/validation/keywords_test.go | 298 -------- pkg/go/validation/messages.go | 259 +++++++ pkg/go/validation/multi_file_validation.go | 205 +----- .../validation/multi_file_validation_test.go | 288 -------- pkg/go/validation/name_validation.go | 251 ++----- pkg/go/validation/name_validation_test.go | 695 +++--------------- pkg/go/validation/schema_validation.go | 90 +-- pkg/go/validation/schema_validation_test.go | 409 ++--------- pkg/go/validation/semantic_validation.go | 321 +++----- pkg/go/validation/semantic_validation_test.go | 298 -------- pkg/go/validation/severity_fixtures_test.go | 205 ------ pkg/go/validation/severity_predicates_test.go | 367 --------- pkg/go/validation/source.go | 266 +++++++ pkg/go/validation/source_test.go | 160 ++++ pkg/go/validation/test_helpers_test.go | 4 - .../testdata/severity-category-cases.yaml | 137 ---- pkg/go/validation/validate.go | 65 ++ pkg/go/validation/validate_test.go | 253 +++++++ pkg/go/validation/validation_engine.go | 230 ------ pkg/go/validation/validation_engine_test.go | 618 ---------------- pkg/go/validation/wildcard_validation.go | 187 ++--- pkg/go/validation/yaml_integration_test.go | 46 +- .../validation/yaml_test_integration_test.go | 56 +- 54 files changed, 2361 insertions(+), 10157 deletions(-) delete mode 100644 pkg/go/errors/doc.go delete mode 100644 pkg/go/errors/example_test.go delete mode 100644 pkg/go/errors/model_error.go delete mode 100644 pkg/go/errors/model_error_kind.go delete mode 100644 pkg/go/errors/model_error_test.go delete mode 100644 pkg/go/errors/sentinels.go delete mode 100644 pkg/go/errors/severity.go delete mode 100644 pkg/go/validation/condition_validation_test.go delete mode 100644 pkg/go/validation/context.go delete mode 100644 pkg/go/validation/context_test.go delete mode 100644 pkg/go/validation/criticality_test.go delete mode 100644 pkg/go/validation/duplicate_detection_test.go delete mode 100644 pkg/go/validation/error_builders.go delete mode 100644 pkg/go/validation/error_builders_test.go delete mode 100644 pkg/go/validation/error_construction.go delete mode 100644 pkg/go/validation/error_info.go delete mode 100644 pkg/go/validation/error_info_integration_test.go delete mode 100644 pkg/go/validation/error_info_test.go delete mode 100644 pkg/go/validation/errors.go delete mode 100644 pkg/go/validation/errors_test.go create mode 100644 pkg/go/validation/findings.go create mode 100644 pkg/go/validation/findings_test.go create mode 100644 pkg/go/validation/index.go delete mode 100644 pkg/go/validation/keywords.go delete mode 100644 pkg/go/validation/keywords_test.go create mode 100644 pkg/go/validation/messages.go delete mode 100644 pkg/go/validation/multi_file_validation_test.go delete mode 100644 pkg/go/validation/semantic_validation_test.go delete mode 100644 pkg/go/validation/severity_fixtures_test.go delete mode 100644 pkg/go/validation/severity_predicates_test.go create mode 100644 pkg/go/validation/source.go create mode 100644 pkg/go/validation/source_test.go delete mode 100644 pkg/go/validation/test_helpers_test.go delete mode 100644 pkg/go/validation/testdata/severity-category-cases.yaml create mode 100644 pkg/go/validation/validate.go create mode 100644 pkg/go/validation/validate_test.go delete mode 100644 pkg/go/validation/validation_engine.go delete mode 100644 pkg/go/validation/validation_engine_test.go diff --git a/pkg/go/errors/doc.go b/pkg/go/errors/doc.go deleted file mode 100644 index dcf74db6..00000000 --- a/pkg/go/errors/doc.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package errors holds the error types and sentinels that validation findings are -// built from, so callers match on values rather than on message text. -// -// A finding has two parts. The sentinel says what the problem is and is matched -// with errors.Is; see sentinels.go. The scope says which part of a model the -// problem is in, is the type the sentinel arrives wrapped in, and is matched with -// errors.As: ErrObjectType, ErrRelation, ErrRelationCondition, ErrCondition, or -// ErrModel when no single part is responsible. -// -// Those five are the implementations of ModelError, so a caller that does not care -// which one it holds can read Kind and Scope off the interface instead. -// -// For a consumer that sees only serialised output, ModelErrorKind is the scope as -// a name and Severity is whether the finding blocks. Both reserve zero for "not -// set" and serialise as their name, so the names are API and the numbers are not. -package errors diff --git a/pkg/go/errors/example_test.go b/pkg/go/errors/example_test.go deleted file mode 100644 index 80ff7725..00000000 --- a/pkg/go/errors/example_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package errors_test - -import ( - "errors" - "fmt" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// Example_reasonAndScope shows how a caller recovers the sentinel with errors.Is -// and the scope with errors.As. -func Example_reasonAndScope() { - // A validator raises the sentinel wrapped in the scope it was raised at. - err := error(&fgaerrors.ErrRelation{ - ObjectType: "document", - Relation: "viewer", - Cause: fgaerrors.ErrNoEntrypoints, - }) - - // The sentinel, through however many layers of wrapping. - if errors.Is(err, fgaerrors.ErrNoEntrypoints) { - fmt.Println("reason: no entrypoints") - } - - // The scope, with the fields that scope declares. - var relationErr *fgaerrors.ErrRelation - if errors.As(err, &relationErr) { - fmt.Printf("scope: %s#%s\n", relationErr.ObjectType, relationErr.Relation) - } - - // A different scope does not match. - var conditionErr *fgaerrors.ErrCondition - fmt.Println("condition scope:", errors.As(err, &conditionErr)) - - // Output: - // reason: no entrypoints - // scope: document#viewer - // condition scope: false -} - -// ExampleSeverity_Blocks shows that only one severity makes a model invalid. -func ExampleSeverity_Blocks() { - for _, severity := range []fgaerrors.Severity{ - fgaerrors.SeverityError, - fgaerrors.SeverityWarning, - fgaerrors.SeverityAdvisory, - } { - fmt.Printf("%s blocks: %t\n", severity, severity.Blocks()) - } - - // Output: - // error blocks: true - // warning blocks: false - // advisory blocks: false -} diff --git a/pkg/go/errors/model_error.go b/pkg/go/errors/model_error.go deleted file mode 100644 index 45a718fe..00000000 --- a/pkg/go/errors/model_error.go +++ /dev/null @@ -1,240 +0,0 @@ -package errors - -import "fmt" - -// The errors here name the part of a model a finding is about, and each declares -// only the fields its scope has: an ErrCondition has no relation, an -// ErrRelationCondition has all three. Cause holds the sentinel. -// -// var relationErr *errors.ErrRelation -// if errors.As(err, &relationErr) { -// fmt.Println(relationErr.ObjectType, relationErr.Relation) -// } -// -// They are named for the scope rather than the problem because many sentinels -// share a scope, and the shapes match the server's pkg/typesystem. Every exported -// error name here is Err-prefixed, types as well as sentinel values, so each type -// below opts out of errname's XxxError rule. - -// ModelError is the cause of a validation finding: a sentinel naming the problem, -// wrapped in the part of the model it was found in. -// -// A caller reaches the problem with errors.Is, and the part of the model either -// with errors.As on one of the concrete types, or through Kind and Scope when the -// concrete type does not matter: -// -// var modelErr errors.ModelError -// if errors.As(err, &modelErr) { -// fmt.Println(modelErr.Kind(), modelErr.Scope().Relation) -// } -// -// The interface has an unexported method, so the five types below are its only -// implementations and a Kind always corresponds to one of them. -type ModelError interface { - error - - // Kind reports which part of the model this finding is attached to. - Kind() ModelErrorKind - - // Scope names that part. Which fields are set follows from Kind; the rest are - // empty. - Scope() ModelErrorScope - - // Unwrap returns the sentinel, so errors.Is reaches it through this error. - Unwrap() error - - // withSentinel returns a copy reporting sentinel as the problem, leaving the - // receiver alone. WithSentinel is the exported way in. - withSentinel(sentinel error) ModelError -} - -// ModelErrorScope names the part of a model a finding is attached to, for a caller -// that wants the names without switching on the concrete type. A finding about the -// model as a whole has none of the three set. -type ModelErrorScope struct { - ObjectType string - Relation string - Condition string -} - -// WithSentinel returns err reporting sentinel as the problem it names, leaving err -// unchanged. It lets the part of the model at fault and the problem be decided in -// different places: whoever finds the fault builds the scope, and whichever code is -// being raised supplies the sentinel. -// -// A nil sentinel yields nil rather than an error whose message reports nothing. -func WithSentinel(err ModelError, sentinel error) ModelError { - if err == nil || sentinel == nil { - return nil - } - - return err.withSentinel(sentinel) -} - -// Every scope type is a ModelError; a new one that forgets a method fails to build -// here rather than at whichever call site first needs it. -var ( - _ ModelError = (*ErrObjectType)(nil) - _ ModelError = (*ErrRelation)(nil) - _ ModelError = (*ErrRelationCondition)(nil) - _ ModelError = (*ErrCondition)(nil) - _ ModelError = (*ErrModel)(nil) -) - -// ErrObjectType is a finding about an object type as a whole. -// -//nolint:errname // Err-prefixed by convention here; see the naming note above -type ErrObjectType struct { - ObjectType string - Cause error -} - -func (e *ErrObjectType) Error() string { - return fmt.Sprintf("error in the definition of the object type '%s': %s", e.ObjectType, e.Cause) -} - -func (e *ErrObjectType) Unwrap() error { - return e.Cause -} - -func (e *ErrObjectType) Kind() ModelErrorKind { - return ErrorKindObjectType -} - -func (e *ErrObjectType) Scope() ModelErrorScope { - return ModelErrorScope{ObjectType: e.ObjectType} -} - -func (e *ErrObjectType) withSentinel(sentinel error) ModelError { - return &ErrObjectType{ObjectType: e.ObjectType, Cause: sentinel} -} - -// ErrRelation is a finding about a relation on an object type. -// -//nolint:errname // Err-prefixed by convention here; see the naming note above -type ErrRelation struct { - ObjectType string - Relation string - Cause error -} - -func (e *ErrRelation) Error() string { - if e.ObjectType == "" { - return fmt.Sprintf("error in the definition of relation '%s': %s", e.Relation, e.Cause) - } - - return fmt.Sprintf("error in the definition of relation '%s' of object type '%s': %s", - e.Relation, e.ObjectType, e.Cause) -} - -func (e *ErrRelation) Unwrap() error { - return e.Cause -} - -func (e *ErrRelation) Kind() ModelErrorKind { - return ErrorKindRelation -} - -func (e *ErrRelation) Scope() ModelErrorScope { - return ModelErrorScope{ObjectType: e.ObjectType, Relation: e.Relation} -} - -func (e *ErrRelation) withSentinel(sentinel error) ModelError { - return &ErrRelation{ObjectType: e.ObjectType, Relation: e.Relation, Cause: sentinel} -} - -// ErrRelationCondition is a finding about a condition as applied to one relation, -// rather than about the condition's own definition. -// -//nolint:errname // Err-prefixed by convention here; see the naming note above -type ErrRelationCondition struct { - ObjectType string - Relation string - Condition string - Cause error -} - -func (e *ErrRelationCondition) Error() string { - return fmt.Sprintf("error in the definition of condition '%s' of relation '%s' in object type '%s': %s", - e.Condition, e.Relation, e.ObjectType, e.Cause) -} - -func (e *ErrRelationCondition) Unwrap() error { - return e.Cause -} - -func (e *ErrRelationCondition) Kind() ModelErrorKind { - return ErrorKindRelationCondition -} - -func (e *ErrRelationCondition) Scope() ModelErrorScope { - return ModelErrorScope{ObjectType: e.ObjectType, Relation: e.Relation, Condition: e.Condition} -} - -func (e *ErrRelationCondition) withSentinel(sentinel error) ModelError { - return &ErrRelationCondition{ - ObjectType: e.ObjectType, - Relation: e.Relation, - Condition: e.Condition, - Cause: sentinel, - } -} - -// ErrCondition is a finding about a condition definition itself, independent of -// where it is applied. -// -//nolint:errname // Err-prefixed by convention here; see the naming note above -type ErrCondition struct { - Condition string - Cause error -} - -func (e *ErrCondition) Error() string { - return fmt.Sprintf("error in the definition of condition '%s': %s", e.Condition, e.Cause) -} - -func (e *ErrCondition) Unwrap() error { - return e.Cause -} - -func (e *ErrCondition) Kind() ModelErrorKind { - return ErrorKindCondition -} - -func (e *ErrCondition) Scope() ModelErrorScope { - return ModelErrorScope{Condition: e.Condition} -} - -func (e *ErrCondition) withSentinel(sentinel error) ModelError { - return &ErrCondition{Condition: e.Condition, Cause: sentinel} -} - -// ErrModel is a finding about the model as a whole, which cannot be attributed -// to a single type, relation or condition. -// -//nolint:errname // Err-prefixed by convention here; see the naming note above -type ErrModel struct { - Cause error -} - -func (e *ErrModel) Error() string { - return fmt.Sprintf("error in authorization model: %s", e.Cause) -} - -func (e *ErrModel) Unwrap() error { - return e.Cause -} - -func (e *ErrModel) Kind() ModelErrorKind { - return ErrorKindInvalidModel -} - -// Scope returns an empty scope: a finding about the model as a whole names no type, -// relation or condition, which is what distinguishes it from the other four. -func (e *ErrModel) Scope() ModelErrorScope { - return ModelErrorScope{} -} - -func (e *ErrModel) withSentinel(sentinel error) ModelError { - return &ErrModel{Cause: sentinel} -} diff --git a/pkg/go/errors/model_error_kind.go b/pkg/go/errors/model_error_kind.go deleted file mode 100644 index 7765a54b..00000000 --- a/pkg/go/errors/model_error_kind.go +++ /dev/null @@ -1,112 +0,0 @@ -package errors - -import "fmt" - -// ModelErrorKind is the part of a model a finding is attached to: which kind of -// thing is wrong, as against the specific problem (the error code) and the -// identity of the thing (the finding's metadata). -// -// It serialises as its wire name, so the names below are API and the numbers are -// not. -type ModelErrorKind int - -// ModelErrorKindUnspecified is the zero value, reserved so that a finding which -// never set a category cannot pass for one that did. It has no wire name and -// omitempty drops it. -const ModelErrorKindUnspecified ModelErrorKind = 0 - -const ( - // ErrorKindObjectType is a finding about an object type as a whole. - ErrorKindObjectType ModelErrorKind = iota + 1 - - // ErrorKindRelation is a finding about a relation on an object type. - ErrorKindRelation - - // ErrorKindRelationCondition is a finding about a condition applied to a - // relation on an object type. - ErrorKindRelationCondition - - // ErrorKindCondition is a finding about a condition definition itself, - // independent of where it is applied. - ErrorKindCondition - - // ErrorKindInvalidModel is a finding about the model as a whole, which - // cannot be attributed to a single type, relation or condition. - ErrorKindInvalidModel -) - -// modelErrorKindFromName maps a wire name back to its category. -func modelErrorKindFromName(name string) (ModelErrorKind, bool) { - switch name { - case "object-type": - return ErrorKindObjectType, true - case "relation": - return ErrorKindRelation, true - case "relation-condition": - return ErrorKindRelationCondition, true - case "condition": - return ErrorKindCondition, true - case "invalid-model": - return ErrorKindInvalidModel, true - default: - return ModelErrorKindUnspecified, false - } -} - -// String returns the wire name of a declared category, an empty string for the -// zero value, and a diagnostic form for any other number. -func (m ModelErrorKind) String() string { - switch m { - case ErrorKindObjectType: - return "object-type" - case ErrorKindRelation: - return "relation" - case ErrorKindRelationCondition: - return "relation-condition" - case ErrorKindCondition: - return "condition" - case ErrorKindInvalidModel: - return "invalid-model" - case ModelErrorKindUnspecified: - return "" - default: - return fmt.Sprintf("ModelErrorKind(%d)", int(m)) - } -} - -// IsValid reports whether m is a declared category. -func (m ModelErrorKind) IsValid() bool { - switch m { - case ErrorKindObjectType, - ErrorKindRelation, - ErrorKindRelationCondition, - ErrorKindCondition, - ErrorKindInvalidModel: - return true - default: - return false - } -} - -// MarshalText emits the wire name, so the JSON carries "object-type". An -// undeclared value must fail to marshal rather than ship String's diagnostic form. -func (m ModelErrorKind) MarshalText() ([]byte, error) { - if !m.IsValid() { - return nil, fmt.Errorf("%w: %d", ErrUnknownModelErrorKind, int(m)) - } - - return []byte(m.String()), nil -} - -// UnmarshalText resolves a wire name back to its category, rejecting any name -// this package does not declare. -func (m *ModelErrorKind) UnmarshalText(text []byte) error { - errorType, ok := modelErrorKindFromName(string(text)) - if !ok { - return fmt.Errorf("%w: %q", ErrUnknownModelErrorKind, text) - } - - *m = errorType - - return nil -} diff --git a/pkg/go/errors/model_error_test.go b/pkg/go/errors/model_error_test.go deleted file mode 100644 index a3201936..00000000 --- a/pkg/go/errors/model_error_test.go +++ /dev/null @@ -1,412 +0,0 @@ -package errors_test - -import ( - "encoding/json" - "errors" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// TestErrorsIsReachesCauseThroughScope checks errors.Is finds the sentinel through -// the scope type, so a caller branches on it without matching message text. -func TestErrorsIsReachesCauseThroughScope(t *testing.T) { - t.Parallel() - - err := &fgaerrors.ErrRelation{ - ObjectType: "document", - Relation: "viewer", - Cause: fgaerrors.ErrNoEntrypoints, - } - - require.ErrorIs(t, err, fgaerrors.ErrNoEntrypoints) - assert.NotErrorIs(t, err, fgaerrors.ErrReservedKeywords, - "must not match a sentinel it does not wrap") -} - -// TestErrorsIsWorksThroughFurtherWrapping checks the cause survives a caller -// adding its own context, which is the normal way these errors travel. -func TestErrorsIsWorksThroughFurtherWrapping(t *testing.T) { - t.Parallel() - - inner := &fgaerrors.ErrObjectType{ - ObjectType: "document", - Cause: fgaerrors.ErrDuplicateDefinition, - } - outer := fmt.Errorf("validating model: %w", inner) - - require.ErrorIs(t, outer, fgaerrors.ErrDuplicateDefinition) - - var objectTypeErr *fgaerrors.ErrObjectType - require.ErrorAs(t, outer, &objectTypeErr, "errors.As must find the scope through fmt.Errorf") - assert.Equal(t, "document", objectTypeErr.ObjectType) -} - -// TestErrorsAsExposesScope checks errors.As recovers each scope type with its -// fields intact, and that the message names them. -func TestErrorsAsExposesScope(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - err error - wantMessageSubstr string - wantCause error - wantScope func(t *testing.T, err error) - }{ - "object type": { - err: &fgaerrors.ErrObjectType{ - ObjectType: "document", - Cause: fgaerrors.ErrReservedKeywords, - }, - wantMessageSubstr: "the object type 'document'", - wantCause: fgaerrors.ErrReservedKeywords, - wantScope: func(t *testing.T, err error) { - t.Helper() - - var scoped *fgaerrors.ErrObjectType - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "document", scoped.ObjectType) - }, - }, - "relation": { - err: &fgaerrors.ErrRelation{ - ObjectType: "document", - Relation: "viewer", - Cause: fgaerrors.ErrNoEntrypoints, - }, - wantMessageSubstr: "relation 'viewer' of object type 'document'", - wantCause: fgaerrors.ErrNoEntrypoints, - wantScope: func(t *testing.T, err error) { - t.Helper() - - var scoped *fgaerrors.ErrRelation - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "document", scoped.ObjectType) - assert.Equal(t, "viewer", scoped.Relation) - }, - }, - "relation condition": { - err: &fgaerrors.ErrRelationCondition{ - ObjectType: "document", - Relation: "viewer", - Condition: "inRegion", - Cause: fgaerrors.ErrConditionUndefined, - }, - wantMessageSubstr: "condition 'inRegion' of relation 'viewer' in object type 'document'", - wantCause: fgaerrors.ErrConditionUndefined, - wantScope: func(t *testing.T, err error) { - t.Helper() - - var scoped *fgaerrors.ErrRelationCondition - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "document", scoped.ObjectType) - assert.Equal(t, "viewer", scoped.Relation) - assert.Equal(t, "inRegion", scoped.Condition) - }, - }, - "condition": { - err: &fgaerrors.ErrCondition{ - Condition: "inRegion", - Cause: fgaerrors.ErrConditionUnReferenced, - }, - wantMessageSubstr: "condition 'inRegion'", - wantCause: fgaerrors.ErrConditionUnReferenced, - wantScope: func(t *testing.T, err error) { - t.Helper() - - var scoped *fgaerrors.ErrCondition - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "inRegion", scoped.Condition) - }, - }, - "model": { - err: &fgaerrors.ErrModel{Cause: fgaerrors.ErrMultipleModulesInFile}, - wantMessageSubstr: "error in authorization model", - wantCause: fgaerrors.ErrMultipleModulesInFile, - wantScope: func(t *testing.T, err error) { - t.Helper() - - var scoped *fgaerrors.ErrModel - require.ErrorAs(t, err, &scoped) - }, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - test.wantScope(t, test.err) - - assert.Contains(t, test.err.Error(), test.wantMessageSubstr) - - // The cause must survive into the message as well as into errors.Is; - // a message naming a relation but not the problem is not actionable. - assert.Contains(t, test.err.Error(), test.wantCause.Error()) - assert.ErrorIs(t, test.err, test.wantCause) - }) - } -} - -// TestScopedErrorsDoNotMatchEachOther checks the scopes are mutually exclusive: -// errors.As for one scope does not match a different one. -func TestScopedErrorsDoNotMatchEachOther(t *testing.T) { - t.Parallel() - - conditionErr := error(&fgaerrors.ErrCondition{ - Condition: "inRegion", - Cause: fgaerrors.ErrConditionUndefined, - }) - - var relationConditionErr *fgaerrors.ErrRelationCondition - assert.False(t, errors.As(conditionErr, &relationConditionErr), - "a condition definition finding must not pass for one about a relation's condition") - - var relationErr *fgaerrors.ErrRelation - assert.False(t, errors.As(conditionErr, &relationErr)) -} - -// TestRelationErrorWithoutObjectType covers relation-scoped raise sites that -// have no object type to attach: the message must not name an empty object -// type. -func TestRelationErrorWithoutObjectType(t *testing.T) { - t.Parallel() - - err := &fgaerrors.ErrRelation{ - Relation: "self", - Cause: fgaerrors.ErrReservedKeywords, - } - - assert.NotContains(t, err.Error(), "object type ''") - assert.Contains(t, err.Error(), "relation 'self'") - assert.Contains(t, err.Error(), fgaerrors.ErrReservedKeywords.Error()) -} - -func TestSeverityBlocks(t *testing.T) { - t.Parallel() - - assert.True(t, fgaerrors.SeverityError.Blocks(), "an error must fail validation") - assert.False(t, fgaerrors.SeverityWarning.Blocks(), "a warning must not fail a valid model") - assert.False(t, fgaerrors.SeverityAdvisory.Blocks(), "an advisory must not fail a valid model") - - // Only the two non-blocking severities answer false, so anything unrecognised - // blocks rather than letting an invalid model pass. - assert.True(t, fgaerrors.SeverityUnspecified.Blocks(), - "a finding that never set a severity must block") - assert.True(t, fgaerrors.Severity(99).Blocks(), - "a severity this package does not declare must block") -} - -// TestSeverityWireNames checks each severity marshals and unmarshals under its wire -// name. The names are API: the Go severity fixtures assert on them. -func TestSeverityWireNames(t *testing.T) { - t.Parallel() - - tests := map[fgaerrors.Severity]string{ - fgaerrors.SeverityError: `"error"`, - fgaerrors.SeverityWarning: `"warning"`, - fgaerrors.SeverityAdvisory: `"advisory"`, - } - - for severity, wantJSON := range tests { - t.Run(severity.String(), func(t *testing.T) { - t.Parallel() - - encoded, err := json.Marshal(severity) - require.NoError(t, err) - assert.JSONEq(t, wantJSON, string(encoded)) - - var decoded fgaerrors.Severity - require.NoError(t, json.Unmarshal(encoded, &decoded)) - assert.Equal(t, severity, decoded, "round trip must be lossless") - - assert.True(t, severity.IsValid()) - }) - } -} - -// TestSeverityRejectsUnknownValues covers both ends of the mapping: a number with -// no name must not reach a consumer as a bare integer, and a name this package -// does not declare must not decode to a severity. -func TestSeverityRejectsUnknownValues(t *testing.T) { - t.Parallel() - - _, err := json.Marshal(fgaerrors.Severity(99)) - require.ErrorIs(t, err, fgaerrors.ErrUnknownSeverity, - "an undeclared severity must fail to marshal rather than ship as 99") - - var decoded fgaerrors.Severity - require.ErrorIs(t, json.Unmarshal([]byte(`"critical"`), &decoded), - fgaerrors.ErrUnknownSeverity) - - assert.False(t, fgaerrors.Severity(99).IsValid()) - assert.False(t, fgaerrors.SeverityUnspecified.IsValid()) -} - -// TestSeverityUnspecifiedIsNotASeverity checks the zero value is not a severity. -// The constants count from one so a finding that never set a severity does not read -// as an error. -func TestSeverityUnspecifiedIsNotASeverity(t *testing.T) { - t.Parallel() - - assert.NotEqual(t, fgaerrors.SeverityUnspecified, fgaerrors.SeverityError, - "the first real severity must not be the zero value") - assert.Equal(t, 0, int(fgaerrors.SeverityUnspecified)) - assert.Empty(t, fgaerrors.SeverityUnspecified.String()) - - // omitempty drops it, so a finding with no severity ships without the field - // rather than claiming to be an error. - encoded, err := json.Marshal(struct { - Severity fgaerrors.Severity `json:"severity,omitempty"` - }{}) - require.NoError(t, err) - assert.JSONEq(t, `{}`, string(encoded)) -} - -// TestSeverityIsAWireNameAsAMapKey covers ValidationSummary.FindingsBySeverity, -// which is keyed by Severity. For a map key the encoding/json package consults -// neither String nor MarshalJSON, only MarshalText, so the counts stay keyed by -// name rather than by number. -func TestSeverityIsAWireNameAsAMapKey(t *testing.T) { - t.Parallel() - - encoded, err := json.Marshal(map[fgaerrors.Severity]int{ - fgaerrors.SeverityError: 3, - fgaerrors.SeverityWarning: 1, - fgaerrors.SeverityAdvisory: 2, - }) - require.NoError(t, err) - assert.JSONEq(t, `{"error":3,"warning":1,"advisory":2}`, string(encoded)) - - // Decoding a map key takes the same route: the key type has to implement - // UnmarshalText for encoding/json to reach it at all, otherwise an integer key - // is parsed as a number and "error" fails. - var decoded map[fgaerrors.Severity]int - require.NoError(t, json.Unmarshal(encoded, &decoded)) - assert.Equal(t, map[fgaerrors.Severity]int{ - fgaerrors.SeverityError: 3, - fgaerrors.SeverityWarning: 1, - fgaerrors.SeverityAdvisory: 2, - }, decoded) -} - -// TestModelErrorKindWireNames checks each category marshals and unmarshals under -// its wire name. The names are API: the Go severity fixtures assert on them. -func TestModelErrorKindWireNames(t *testing.T) { - t.Parallel() - - tests := map[fgaerrors.ModelErrorKind]string{ - fgaerrors.ErrorKindObjectType: `"object-type"`, - fgaerrors.ErrorKindRelation: `"relation"`, - fgaerrors.ErrorKindRelationCondition: `"relation-condition"`, - fgaerrors.ErrorKindCondition: `"condition"`, - fgaerrors.ErrorKindInvalidModel: `"invalid-model"`, - } - - for errorType, wantJSON := range tests { - t.Run(errorType.String(), func(t *testing.T) { - t.Parallel() - - encoded, err := json.Marshal(errorType) - require.NoError(t, err) - assert.JSONEq(t, wantJSON, string(encoded)) - - var decoded fgaerrors.ModelErrorKind - require.NoError(t, json.Unmarshal(encoded, &decoded)) - assert.Equal(t, errorType, decoded, "round trip must be lossless") - - assert.True(t, errorType.IsValid()) - }) - } -} - -// TestModelErrorKindRejectsUnknownValues covers both ends of the mapping: a -// number with no name must not reach a consumer as a bare integer, and a name -// this package does not declare must not decode to a category. -func TestModelErrorKindRejectsUnknownValues(t *testing.T) { - t.Parallel() - - _, err := json.Marshal(fgaerrors.ModelErrorKind(99)) - require.ErrorIs(t, err, fgaerrors.ErrUnknownModelErrorKind, - "an undeclared category must fail to marshal rather than ship as 99") - - var decoded fgaerrors.ModelErrorKind - require.ErrorIs(t, json.Unmarshal([]byte(`"not-a-category"`), &decoded), - fgaerrors.ErrUnknownModelErrorKind) - - assert.False(t, fgaerrors.ModelErrorKind(99).IsValid()) - assert.False(t, fgaerrors.ModelErrorKindUnspecified.IsValid()) -} - -// TestModelErrorKindUnspecifiedIsNotACategory checks the zero value is not a -// category. The constants count from one so a finding that never set one does not -// read as being about an object type. -func TestModelErrorKindUnspecifiedIsNotACategory(t *testing.T) { - t.Parallel() - - assert.NotEqual(t, fgaerrors.ModelErrorKindUnspecified, fgaerrors.ErrorKindObjectType, - "the first real category must not be the zero value") - assert.Equal(t, 0, int(fgaerrors.ModelErrorKindUnspecified)) - assert.Empty(t, fgaerrors.ModelErrorKindUnspecified.String()) - - // omitempty drops it, so a finding with no category ships without the field - // rather than with a wrong one. - encoded, err := json.Marshal(struct { - Category fgaerrors.ModelErrorKind `json:"category,omitempty"` - }{}) - require.NoError(t, err) - assert.JSONEq(t, `{}`, string(encoded)) -} - -// TestSentinelsAreDistinct guards against a copy-paste leaving two names pointing -// at one value, which would make errors.Is match the wrong condition. -func TestSentinelsAreDistinct(t *testing.T) { - t.Parallel() - - sentinels := map[string]error{ - "ErrInvalidSchemaVersion": fgaerrors.ErrInvalidSchemaVersion, - "ErrSchemaVersionUnsupported": fgaerrors.ErrSchemaVersionUnsupported, - "ErrSchemaVersionRequired": fgaerrors.ErrSchemaVersionRequired, - "ErrReservedKeywords": fgaerrors.ErrReservedKeywords, - "ErrInvalidName": fgaerrors.ErrInvalidName, - "ErrDuplicateDefinition": fgaerrors.ErrDuplicateDefinition, - "ErrObjectTypeUndefined": fgaerrors.ErrObjectTypeUndefined, - "ErrRelationUndefined": fgaerrors.ErrRelationUndefined, - "ErrInvalidType": fgaerrors.ErrInvalidType, - "ErrInvalidRelationType": fgaerrors.ErrInvalidRelationType, - "ErrInvalidRelationOnTupleset": fgaerrors.ErrInvalidRelationOnTupleset, - "ErrInvalidRelationOnTuplesetNotDirect": fgaerrors.ErrInvalidRelationOnTuplesetNotDirect, - "ErrNoEntrypoints": fgaerrors.ErrNoEntrypoints, - "ErrDirectlyAssignableRelation": fgaerrors.ErrDirectlyAssignableRelation, - "ErrInvalidWildcard": fgaerrors.ErrInvalidWildcard, - "ErrConditionUndefined": fgaerrors.ErrConditionUndefined, - "ErrConditionUnReferenced": fgaerrors.ErrConditionUnReferenced, - "ErrConditionNameMismatch": fgaerrors.ErrConditionNameMismatch, - "ErrMultipleModulesInFile": fgaerrors.ErrMultipleModulesInFile, - } - - seenMessages := make(map[string]string, len(sentinels)) - - for name, sentinel := range sentinels { - require.Errorf(t, sentinel, "%s is nil", name) - - for otherName, other := range sentinels { - if name == otherName { - continue - } - - require.NotErrorIsf(t, sentinel, other, - "%s and %s are the same value; errors.Is cannot tell them apart", name, otherName) - } - - if previous, duplicate := seenMessages[sentinel.Error()]; duplicate { - t.Errorf("%s and %s have the identical message %q", name, previous, sentinel.Error()) - } - - seenMessages[sentinel.Error()] = name - } -} diff --git a/pkg/go/errors/sentinels.go b/pkg/go/errors/sentinels.go deleted file mode 100644 index 73851944..00000000 --- a/pkg/go/errors/sentinels.go +++ /dev/null @@ -1,99 +0,0 @@ -package errors - -import "errors" - -// Sentinel errors for the conditions model validation reports. -// -// Callers branch on what went wrong with errors.Is rather than on message text. -// Every validation finding wraps exactly one of these, and only conditions the -// validator actually reports get one. -var ( - // ErrInvalidSchemaVersion is reported for a schema version that was never a - // valid one, as against one that is no longer supported. - ErrInvalidSchemaVersion = errors.New("invalid schema version") - - // ErrSchemaVersionUnsupported is reported for a schema version that was - // once valid but is no longer supported. - ErrSchemaVersionUnsupported = errors.New("schema version no longer supported") - - // ErrSchemaVersionRequired is reported when a model declares no schema - // version. - ErrSchemaVersionRequired = errors.New("schema version required") - - // ErrReservedKeywords is reported when a type or relation is named with a - // reserved word. - ErrReservedKeywords = errors.New("self and this are reserved keywords") - - // ErrInvalidName is reported when a type or relation name does not match - // the naming rules. - ErrInvalidName = errors.New("invalid name") - - // ErrDuplicateDefinition is reported when a type, relation or type - // restriction is defined more than once. - ErrDuplicateDefinition = errors.New("duplicate definition") - - // ErrObjectTypeUndefined is reported when a model references an object type - // that is not defined. - ErrObjectTypeUndefined = errors.New("undefined object type") - - // ErrRelationUndefined is reported when a model references a relation that - // is not defined on the type it is used with. - ErrRelationUndefined = errors.New("undefined relation") - - // ErrInvalidType is reported when a type restriction names something that - // is not a valid type. - ErrInvalidType = errors.New("invalid type") - - // ErrInvalidRelationType is reported when a relation is not valid for the - // type it is referenced against. - ErrInvalidRelationType = errors.New("invalid relation for type") - - // ErrInvalidRelationOnTupleset is reported when a tupleset relation - // references a relation that does not exist on the related type. - ErrInvalidRelationOnTupleset = errors.New("invalid relation on tupleset") - - // ErrInvalidRelationOnTuplesetNotDirect is reported when a relation used - // inside a `from` clause is not a direct relation. - ErrInvalidRelationOnTuplesetNotDirect = errors.New( - "relations that are referenced in a tupleset must be defined with a direct relation") - - // ErrNoEntrypoints is reported when a relation can never be satisfied, - // either because nothing can enter it or because it only refers back to - // itself. - ErrNoEntrypoints = errors.New("no entrypoints defined") - - // ErrDirectlyAssignableRelation is reported when an assignable relation - // declares no assignable types. - ErrDirectlyAssignableRelation = errors.New("a direct assignment must contain at least one object type or userset") - - // ErrInvalidWildcard is reported when a wildcard is used somewhere it is - // not permitted, including alongside a relation in the same type - // restriction. - ErrInvalidWildcard = errors.New("invalid wildcard usage") - - // ErrConditionUndefined is reported when a relation references a condition - // that the model does not define. - ErrConditionUndefined = errors.New("condition is not defined") - - // ErrConditionUnReferenced is reported when a condition is defined but - // never used. - ErrConditionUnReferenced = errors.New("condition is defined but not referenced") - - // ErrConditionNameMismatch is reported when a condition's key differs from - // the name declared inside it. - ErrConditionNameMismatch = errors.New("condition name does not match its nested name") - - // ErrMultipleModulesInFile is reported when one file declares more than one - // module. - ErrMultipleModulesInFile = errors.New("file contains multiple modules") - - // ErrUnknownModelErrorKind is returned when a ModelErrorKind has no wire - // name, either marshalling a value this package does not declare or reading - // a name it does not recognise. It is not a validation finding. - ErrUnknownModelErrorKind = errors.New("unknown model error type") - - // ErrUnknownSeverity is returned when a Severity has no wire name, either - // marshalling a value this package does not declare or reading a name it - // does not recognise. It is not a validation finding. - ErrUnknownSeverity = errors.New("unknown severity") -) diff --git a/pkg/go/errors/severity.go b/pkg/go/errors/severity.go deleted file mode 100644 index cc67993c..00000000 --- a/pkg/go/errors/severity.go +++ /dev/null @@ -1,102 +0,0 @@ -package errors - -import "fmt" - -// Severity states whether a finding makes a model invalid, or only reports -// something about a model that stays valid. -// -// It serialises as its wire name, so the names below are API and the numbers are -// not. -type Severity int - -// SeverityUnspecified is the zero value, so a finding that never set a severity -// cannot pass for one that did. It has no wire name, omitempty drops it, and -// Blocks treats it as blocking. -const SeverityUnspecified Severity = 0 - -const ( - // SeverityError means the model is invalid. Validation fails. - SeverityError Severity = iota + 1 - - // SeverityWarning means the model is valid today but relies on something a - // future version may not accept. Validation does not fail. - SeverityWarning - - // SeverityAdvisory means the model is valid, but a request against it may not - // behave the way the author expects, depending on the tuples written and the - // checks issued. Validation does not fail. - SeverityAdvisory -) - -// severityFromName maps a wire name back to its severity. -func severityFromName(name string) (Severity, bool) { - switch name { - case "error": - return SeverityError, true - case "warning": - return SeverityWarning, true - case "advisory": - return SeverityAdvisory, true - default: - return SeverityUnspecified, false - } -} - -// String returns the wire name of a declared severity, an empty string for the -// zero value, and a diagnostic form for any other number. -func (s Severity) String() string { - switch s { - case SeverityError: - return "error" - case SeverityWarning: - return "warning" - case SeverityAdvisory: - return "advisory" - case SeverityUnspecified: - return "" - default: - return fmt.Sprintf("Severity(%d)", int(s)) - } -} - -// IsValid reports whether s is a declared severity. -func (s Severity) IsValid() bool { - switch s { - case SeverityError, SeverityWarning, SeverityAdvisory: - return true - default: - return false - } -} - -// Blocks reports whether a finding of this severity makes validation fail. -// -// Only the severities declared as non-blocking answer false, so an unset or -// undeclared value blocks: a severity that cannot be recognised must not let an -// invalid model pass. -func (s Severity) Blocks() bool { - return s != SeverityWarning && s != SeverityAdvisory -} - -// MarshalText emits the wire name, so the JSON carries "warning". An undeclared -// value must fail to marshal rather than ship String's diagnostic form. -func (s Severity) MarshalText() ([]byte, error) { - if !s.IsValid() { - return nil, fmt.Errorf("%w: %d", ErrUnknownSeverity, int(s)) - } - - return []byte(s.String()), nil -} - -// UnmarshalText resolves a wire name back to its severity, rejecting any name -// this package does not declare. -func (s *Severity) UnmarshalText(text []byte) error { - severity, ok := severityFromName(string(text)) - if !ok { - return fmt.Errorf("%w: %q", ErrUnknownSeverity, text) - } - - *s = severity - - return nil -} diff --git a/pkg/go/validation/complex_operation_validation.go b/pkg/go/validation/complex_operation_validation.go index a53caafe..f74e5281 100644 --- a/pkg/go/validation/complex_operation_validation.go +++ b/pkg/go/validation/complex_operation_validation.go @@ -7,203 +7,162 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ComplexOperationValidator handles validation of complex userset operations. -type ComplexOperationValidator struct { - model *openfgav1.AuthorizationModel - validator *SemanticValidator -} - -func NewComplexOperationValidator(model *openfgav1.AuthorizationModel) *ComplexOperationValidator { - return newComplexOperationValidator(NewSemanticValidator(model)) -} - -func newComplexOperationValidator(validator *SemanticValidator) *ComplexOperationValidator { - return &ComplexOperationValidator{ - model: validator.model, - validator: validator, - } -} - -// ValidateComplexOperations validates all complex operations in the model. -func ValidateComplexOperations(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateComplexOperations(errs, NewSemanticValidator(model), lines) -} - -func validateComplexOperations(errs *ValidationErrors, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - opValidator := newComplexOperationValidator(validator) - for _, typeDef := range model.GetTypeDefinitions() { +// 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) Findings { + var fs Findings + + for _, typeDef := range idx.model.GetTypeDefinitions() { relations := typeDef.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relations)) { - opValidator.validateUsersetOperations(errs, typeDef.GetType(), relationName, - relations[relationName], lines) + fs = append(fs, operationsIn(idx, src, typeDef.GetType(), relationName, + relations[relationName], make(map[string]bool))...) } } -} -func (cov *ComplexOperationValidator) validateUsersetOperations(errs *ValidationErrors, typeName, relationName string, userset *openfgav1.Userset, lines []string) { - cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, userset, lines, make(map[string]bool)) + return fs } -func (cov *ComplexOperationValidator) validateUsersetOperationsWithVisited(errs *ValidationErrors, typeName, relationName string, userset *openfgav1.Userset, lines []string, visited map[string]bool) { +// 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) Findings { if userset == nil { - return + return nil } - if union := userset.GetUnion(); union != nil { - cov.validateUnionOperationWithVisited(errs, typeName, relationName, union, lines, visited) + + var fs Findings + + 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)...) + } } - if intersection := userset.GetIntersection(); intersection != nil { - cov.validateIntersectionOperationWithVisited(errs, typeName, relationName, intersection, lines, visited) + + if intersection := userset.GetIntersection(); intersection != nil && len(intersection.GetChild()) > 0 { + fs = append(fs, impossibleIntersectionsIn(idx, src, typeName, relationName, intersection)...) + + for _, child := range intersection.GetChild() { + fs = append(fs, operationsIn(idx, src, typeName, relationName, child, visited)...) + } } + if diff := userset.GetDifference(); diff != nil { - cov.validateDifferenceOperationWithVisited(errs, 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 = fs.add(emptyDifferenceIn(idx, src, typeName, relationName, diff)) } - cov.validateNestedOperationsWithVisited(errs, typeName, userset, lines, visited) -} -func (cov *ComplexOperationValidator) validateUnionOperationWithVisited(errs *ValidationErrors, typeName, relationName string, union *openfgav1.Usersets, lines []string, visited map[string]bool) { - if union == nil || len(union.GetChild()) == 0 { - return - } - cov.checkRedundantUnionMembers(errs, typeName, relationName, union, lines) - for _, child := range union.GetChild() { - cov.validateUsersetOperationsWithVisited(errs, 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(errs, typeName, relationName, union, lines) -} -func (cov *ComplexOperationValidator) validateIntersectionOperationWithVisited(errs *ValidationErrors, typeName, relationName string, intersection *openfgav1.Usersets, lines []string, visited map[string]bool) { - if intersection == nil || len(intersection.GetChild()) == 0 { - return - } - cov.checkImpossibleIntersections(errs, typeName, relationName, intersection, lines) - for _, child := range intersection.GetChild() { - cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, child, lines, visited) - } - cov.validateIntersectionSemantics(errs, typeName, relationName, intersection, lines) + return fs } -func (cov *ComplexOperationValidator) validateDifferenceOperationWithVisited(errs *ValidationErrors, typeName, relationName string, difference *openfgav1.Difference, lines []string, visited map[string]bool) { - if difference == nil { - return - } - cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, difference.GetBase(), lines, visited) - cov.validateUsersetOperationsWithVisited(errs, typeName, relationName, difference.GetSubtract(), lines, visited) - cov.validateDifferenceSemantics(errs, 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) Findings { + var fs Findings + + seen := make(map[string]bool) -func (cov *ComplexOperationValidator) checkRedundantUnionMembers(errs *ValidationErrors, 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) - errs.Add(newRedundantUnionMemberError(lines, 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(errs *ValidationErrors, 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) Findings { + 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) - errs.Add(newImpossibleIntersectionError(lines, relationName, typeName, typeRestrictions, meta, lineIndex)) + + if len(unique) <= 1 { + return nil } -} -func (cov *ComplexOperationValidator) validateUnionSemantics(errs *ValidationErrors, typeName, relationName string, union *openfgav1.Usersets, lines []string) { - cov.checkSubsumingUnionMembers(errs, typeName, relationName, union, lines) -} + line := src.relationLine(relationName, -1) + file, module := typeMeta(idx.typeDef(typeName)) -func (cov *ComplexOperationValidator) validateIntersectionSemantics(errs *ValidationErrors, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { - cov.checkRedundantIntersectionMembers(errs, typeName, relationName, intersection, lines) + return Findings{impossibleIntersection(relationName, typeName, restrictions).at(src, line).in(file, module)} } -func (cov *ComplexOperationValidator) validateDifferenceSemantics(errs *ValidationErrors, 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) - errs.Add(newEmptyDifferenceError(lines, 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(errs *ValidationErrors, 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(errs, 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(_ *ValidationErrors, _, _ 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(_ *ValidationErrors, _, _ 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 dabe3666..7074e359 100644 --- a/pkg/go/validation/condition_validation.go +++ b/pkg/go/validation/condition_validation.go @@ -7,171 +7,102 @@ import ( 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 +// conditionUse is one place a condition is referenced from: a type restriction +// on a relation. +type conditionUse struct { + typeName string + relationName string } -// ConditionReference tracks where a condition is referenced. -type ConditionReference struct { - TypeName string - RelationName string - Context string -} +// 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) Findings { + uses := conditionUses(model) -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 -} + fs := undefinedConditions(model, src, uses) -func (cv *ConditionValidator) buildConditionMaps() { - if cv.model == nil { - return - } - for conditionName, condition := range cv.model.GetConditions() { - cv.definedConds[conditionName] = condition - } - cv.scanForConditionUsage() -} - -func (cv *ConditionValidator) scanForConditionUsage() { - for _, typeDef := range cv.model.GetTypeDefinitions() { - if metaProto := typeDef.GetMetadata(); metaProto != nil { - // Relations in name order: the references collected here are reported in - // the order they were appended, so ranging the map would vary it. - relationsMetadata := metaProto.GetRelations() - for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { - cv.scanRelationMetadataForConditions(typeDef.GetType(), relationName, - relationsMetadata[relationName]) - } + // 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(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateUnusedConditions(errs, NewConditionValidator(model), lines) -} + condition := conditions[conditionName] + file := condition.GetMetadata().GetSourceInfo().GetFile() + module := condition.GetMetadata().GetModule() -func validateUnusedConditions(errs *ValidationErrors, validator *ConditionValidator, lines []string) { - for _, conditionName := range slices.Sorted(maps.Keys(validator.definedConds)) { - if !validator.usedConds[conditionName] { - condition := validator.definedConds[conditionName] - lineIndex := GetConditionLineNumber(conditionName, lines, nil) - meta := &Meta{ - File: condition.GetMetadata().GetSourceInfo().GetFile(), - Module: condition.GetMetadata().GetModule(), - } - errs.Add(newUnusedConditionError(lines, 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(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateConditionReferences(errs, NewConditionValidator(model), lines) + return fs } -func validateConditionReferences(errs *ValidationErrors, validator *ConditionValidator, lines []string) { - model := validator.model - for _, conditionName := range slices.Sorted(maps.Keys(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} - errs.Add(newInvalidConditionNameInParameterError(lines, 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(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - conditions := model.GetConditions() - for _, conditionKey := range slices.Sorted(maps.Keys(conditions)) { - condition := conditions[conditionKey] - 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) Findings { + var fs Findings + + defined := model.GetConditions() + + for _, conditionName := range slices.Sorted(maps.Keys(uses)) { + if _, ok := defined[conditionName]; ok { continue } - if condition.GetName() != conditionKey { - errs.Add(newDifferentNestedConditionNameError(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 1a7fbb03..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 := NewValidationErrors(nil) - ValidateUnusedConditions(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateUnusedConditions(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateUnusedConditions(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateConditionReferences(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateConditionReferences(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateConditionReferences(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateConditionConsistency(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateConditionConsistency(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateConditionConsistency(collector, model, nil) - - assert.Empty(t, collector.AllFindings()) - }) -} - -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 c5c4f5c2..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/criticality_test.go b/pkg/go/validation/criticality_test.go deleted file mode 100644 index 5ece9e2c..00000000 --- a/pkg/go/validation/criticality_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// TestCriticalImpliesBlocking checks no code is critical without also being -// blocking. Criticality and severity are fields on the same errorInfo entry, so a -// code cannot claim to invalidate the whole model and not fail validation. -func TestCriticalImpliesBlocking(t *testing.T) { - t.Parallel() - - for errorType, info := range errorInfoByType { - if !info.Critical { - continue - } - - assert.Equalf(t, fgaerrors.SeverityError, info.Severity, - "%q is critical but its severity is %q: a finding cannot invalidate the whole "+ - "model and leave it valid", errorType, info.Severity) - } -} - -// TestCriticalErrorTypesAreEmitted checks criticality is only claimed for a code -// some Raise* method raises. -// -// It reads the raise sites in the collector, not the callers of those methods, so a -// code raised only by a Raise* method that nothing calls still counts here. -func TestCriticalErrorTypesAreEmitted(t *testing.T) { - t.Parallel() - - emitted := emittedErrorTypes(t) - require.NotEmpty(t, emitted) - - for errorType, info := range errorInfoByType { - if !info.Critical { - continue - } - - name := errorTypeConstantName(t, errorType) - _, ok := emitted[name] - assert.Truef(t, ok, "%s is marked critical but no Raise* method raises it", name) - } -} - -// TestUnemittedErrorTypesAreNotCritical checks the same from the other side, so a -// change that starts emitting one of these codes has to decide its criticality -// rather than inherit one. -func TestUnemittedErrorTypesAreNotCritical(t *testing.T) { - t.Parallel() - - for errorType := range unemittedErrorTypes { - assert.Falsef(t, isCriticalErrorType(errorType), - "%q is not emitted, so calling it critical asserts nothing", errorType) - } -} - -// TestCriticalityOfEveryEmittedCode pins the criticality of every declared code, so -// a change to errorInfo that alters one has to be made here as well. -func TestCriticalityOfEveryEmittedCode(t *testing.T) { - t.Parallel() - - wantCritical := map[ValidationErrorType]bool{ - RelationNoEntrypoint: true, - UndefinedType: true, - UndefinedRelation: true, - InvalidRelationType: true, - DuplicatedError: true, - InvalidSchema: true, - MultipleModulesInFile: true, - } - - // Nothing raises these two, so they are held at not-critical rather than listed - // above. - neverRaised := map[ValidationErrorType]struct{}{ - CyclicRelation: {}, - InvalidSchemaVersion: {}, - } - - for _, errorType := range allErrorTypes { - if _, unemitted := neverRaised[errorType]; unemitted { - assert.Falsef(t, isCriticalErrorType(errorType), - "%q is never raised and must not be claimed critical", errorType) - - continue - } - - assert.Equalf(t, wantCritical[errorType], isCriticalErrorType(errorType), - "criticality of %q does not match the list in this test", errorType) - } -} - -// TestHasCriticalErrorsThroughValidation checks criticality end to end, and that a -// non-critical error leaves the flag unset. Otherwise the field would be -// indistinguishable from HasErrors. -func TestHasCriticalErrorsThroughValidation(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - dsl string - wantCritical bool - wantValid bool - }{ - "undefined type is an error but not critical": { - dsl: `model - schema 1.1 -type document - relations - define viewer: [user] -`, - wantCritical: false, - wantValid: false, - }, - "duplicate type is critical": { - dsl: `model - schema 1.1 -type user -type document -type document -`, - wantCritical: true, - wantValid: false, - }, - "relation with no entrypoint is critical": { - dsl: `model - schema 1.1 -type user -type document - relations - define viewer: writer - define writer: viewer -`, - wantCritical: true, - wantValid: false, - }, - "valid model has neither": { - dsl: `model - schema 1.1 -type user -type document - relations - define viewer: [user] -`, - wantCritical: false, - wantValid: true, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - report := CreateValidationReport(modelFromDSL(t, test.dsl), test.dsl, DefaultEngineOptions()) - - assert.Equal(t, test.wantCritical, report.HasCriticalErrors()) - assert.Equal(t, test.wantValid, report.IsValid()) - - if test.wantCritical { - require.False(t, report.IsValid(), "a critical finding must also block") - } - }) - } -} diff --git a/pkg/go/validation/cycle_detection.go b/pkg/go/validation/cycle_detection.go index 231dff22..0f488f95 100644 --- a/pkg/go/validation/cycle_detection.go +++ b/pkg/go/validation/cycle_detection.go @@ -13,95 +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} -} - -// 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(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateCyclesAndEntryPoints(errs, NewSemanticValidator(model), lines) -} - -func validateCyclesAndEntryPoints(errs *ValidationErrors, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - detector := NewCycleDetector(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) Findings { + var fs Findings - 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) + typeLine := src.typeLine(typeName) + for _, relationName := range slices.Sorted(maps.Keys(relations)) { - meta := relationMeta(typeDef, relationName) - result := detector.hasEntryPointOrLoop(typeName, relationName, relations[relationName], + result := hasEntryPointOrLoop(idx, typeName, relationName, relations[relationName], map[string]map[string]bool{}) - if !result.hasEntry { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - if result.loop { - errs.Add(newNoEntryPointLoopError(lines, relationName, typeName, meta, lineIndex)) - } else { - errs.Add(newNoEntryPointError(lines, relationName, typeName, meta, lineIndex)) - } + 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 fs } -// hasEntryPointOrLoop determines whether a rewrite reaches a concrete entry point. -// The visited map tracks type#relation pairs already on the current traversal. +// 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. +// 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{} @@ -110,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 f883413c..11bd1d39 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) Findings { + t.Helper() + model, err := transformer.TransformDSLToProto(dsl) if err != nil { t.Fatalf("failed to transform DSL: %v", err) } - lines := strings.Split(dsl, "\n") - collector := NewValidationErrors(nil) - ValidateCyclesAndEntryPoints(collector, model, lines) + return 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.AllFindings()) + "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 := NewValidationErrors(nil) - - 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.AllFindings()) + "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 := NewValidationErrors(nil) + 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 := NewValidationErrors(nil) - - 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.AllFindings() { - 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 := NewValidationErrors(nil) - 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.AllFindings()) + 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 9d95f295..7ebf04cf 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 := NewValidationErrors(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) + findings := validateEntryPoints(newIndex(model), source{}) - errors := collector.AllFindings() - // 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 := NewValidationErrors(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) - assert.Empty(t, collector.AllFindings()) + assert.Empty(t, 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 := NewValidationErrors(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) // All three relations resolve to owner's direct assignment. - assert.Empty(t, collector.AllFindings()) + assert.Empty(t, 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) }) } @@ -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 1b5a9419..e061ccd4 100644 --- a/pkg/go/validation/duplicate_detection.go +++ b/pkg/go/validation/duplicate_detection.go @@ -7,172 +7,158 @@ import ( 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) Findings { + var fs Findings -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 + } -func (dt *DuplicateTypeTracker) CheckAndAddType(typeName string, errs *ValidationErrors, - meta *Meta, lines []string) bool { - if dt.typeNames[typeName] { - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - errs.Add(newDuplicateTypeNameError(lines, typeName, meta, typeLineIndex)) - return false + file, module := typeMeta(typeDef) + + if seenTypes[typeName] { + fs = append(fs, duplicateTypeName(typeName).at(src, src.typeLine(typeName)).in(file, module)) + } + + 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 fs } -// CheckForDuplicateTypeNamesInRelation checks for duplicate type restrictions within a relation. -func CheckForDuplicateTypeNamesInRelation(errs *ValidationErrors, 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) Findings { if relationMetadata == nil { - return + return nil } - typeRestrictions := make(map[string]bool) - for _, typeRestriction := range relationMetadata.GetDirectlyRelatedUserTypes() { - if typeRestriction.GetType() == "" { + + var fs Findings + + 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) - errs.Add(newDuplicateTypeRestrictionError(lines, 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(errs *ValidationErrors, 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) Findings { + 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 Findings + + 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(errs, union, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } - if intersection := relation.GetIntersection(); intersection != nil { - checkDuplicatesInOperands(errs, intersection, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } - if diff := relation.GetDifference(); diff != nil { - checkDuplicatesInDifference(errs, 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(errs *ValidationErrors, 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) - errs.Add(newDuplicateTypeError(lines, 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(errs *ValidationErrors, 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) - errs.Add(newDuplicateTypeError(lines, 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(errs *ValidationErrors, 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, errs, meta, lines) - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - if metaProto := typeDef.GetMetadata(); metaProto != nil { - relationsMetadata := metaProto.GetRelations() - for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { - CheckForDuplicateTypeNamesInRelation(errs, relationsMetadata[relationName], relationName, typeName, meta, typeLineIndex, lines) - CheckForDuplicatesInRelation(errs, 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 ea8f55a6..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 := NewValidationErrors(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - for _, typeName := range tt.typeNames { - tracker.CheckAndAddType(typeName, collector, meta, nil) - } - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - CheckForDuplicateTypeNamesInRelation(collector, tt.relationMetadata, tt.relationName, tt.typeName, meta, nil, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - - CheckForDuplicatesInRelation(collector, tt.typeDef, tt.relationName, nil, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - - ValidateDuplicates(collector, tt.model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - checkDuplicatesInOperands(collector, tt.union, "test_relation", "test_type", meta, nil, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - - // Model with duplicate type names - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - {Type: "document"}, // Duplicate - }, - } - - ValidateDuplicates(collector, model, nil) - errors := collector.AllFindings() - 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 := NewValidationErrors(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.AllFindings() - assert.Len(t, errors, 1) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - }) -} diff --git a/pkg/go/validation/error_builders.go b/pkg/go/validation/error_builders.go deleted file mode 100644 index 6347014f..00000000 --- a/pkg/go/validation/error_builders.go +++ /dev/null @@ -1,323 +0,0 @@ -package validation - -import ( - "fmt" - "strings" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// newInvalidNameError reports a name that breaks a naming rule. A nil typeName means the -// offending name is a type rather than a relation on one, which changes the message and -// the scope. -func newInvalidNameError(lines []string, symbol, clause string, typeName *string, meta *Meta, lineIndex *int) *ValidationError { - var message string - errorScope := scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}} - - if typeName != nil { - message = fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", symbol, *typeName, clause) - errorScope = scope{part: &fgaerrors.ErrRelation{ObjectType: *typeName, Relation: symbol}} - } else { - message = fmt.Sprintf("type '%s' does not match naming rule: '%s'.", symbol, clause) - } - - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, InvalidName, symbol, line, column, errorScope, meta) -} - -// newInvalidConditionNameError reports a condition name that breaks a naming rule, -// scoped to the condition rather than a type or relation. -func newInvalidConditionNameError(lines []string, symbol, clause string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", symbol, clause) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, InvalidName, symbol, line, column, scope{part: &fgaerrors.ErrCondition{Condition: symbol}}, meta) -} - -// newReservedTypeNameError reports a type named with a reserved keyword. -func newReservedTypeNameError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError("a type cannot be named 'self' or 'this'.", ReservedTypeKeywords, symbol, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}}, meta) -} - -// newReservedRelationNameError reports a relation named with a reserved keyword. -func newReservedRelationNameError(lines []string, symbol, typeName string, meta *Meta, lineIndex *int) *ValidationError { - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError("a relation cannot be named 'self' or 'this'.", ReservedRelationKeywords, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) -} - -// newTupleUsersetRequiresDirectError reports a tuple-to-userset that is not direct. Its -// column is resolved past the `from` keyword so it marks the offending relation. -func newTupleUsersetRequiresDirectError(lines []string, symbol, typeName, relation string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("`%s` relation used inside from allows only direct relation.", symbol) - - 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 - } - - line, column := resolvePosition(lines, symbol, lineIndex, customResolver) - return newValidationError(message, TuplesetNotDirect, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}}, meta) -} - -// newDuplicateTypeNameError reports a duplicated type. It is about the type, not a -// relation on it, so it overrides DuplicatedError's relation-scoped default. -func newDuplicateTypeNameError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("the type `%s` is a duplicate.", symbol) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}}, meta) -} - -// newDuplicateTypeRestrictionError reports a duplicated type restriction on a relation. -func newDuplicateTypeRestrictionError(lines []string, symbol, relationName, typeName string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("the type restriction `%s` is a duplicate in the relation `%s`.", symbol, relationName) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) -} - -// newUndefinedTypeError reports a reference to a type that does not exist. The scope -// names the type that is missing, not the relation it was referenced from. -func newUndefinedTypeError(lines []string, typeName, relationName, parentTypeName string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("Type '%s' is not defined (referenced in relation '%s' of type '%s')", typeName, relationName, parentTypeName) - line, column := resolvePosition(lines, typeName, lineIndex, nil) - return newValidationError(message, UndefinedType, typeName, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: typeName}}, meta) -} - -// newUndefinedRelationError reports a reference to a relation that does not exist on its -// type. -func newUndefinedRelationError(lines []string, relationName, typeName, parentRelation, parentTypeName string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("Relation '%s' is not defined on type '%s' (referenced in relation '%s' of type '%s')", relationName, typeName, parentRelation, parentTypeName) - line, column := resolvePosition(lines, relationName, lineIndex, nil) - return newValidationError(message, UndefinedRelation, relationName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) -} - -// newDuplicateTypeError reports a duplicated partial relation definition. -func newDuplicateTypeError(lines []string, symbol, relationName, typeName string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("the partial relation definition `%s` is a duplicate in the relation `%s`.", - symbol, relationName) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) -} - -// newDuplicateRelationshipDefinitionError reports a relation defined more than once. -func newDuplicateRelationshipDefinitionError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("the relation '%s' is defined more than once.", symbol) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{Relation: symbol}}, meta) -} - -// newNoEntryPointLoopError reports an impossible relation with a potential loop. -func newNoEntryPointLoopError(lines []string, symbol, typeName string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("`%s` is an impossible relation for `%s` (potential loop).", symbol, typeName) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, RelationNoEntrypoint, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) -} - -// newNoEntryPointError reports an impossible relation with no entry point. -func newNoEntryPointError(lines []string, symbol, typeName string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("`%s` is an impossible relation for `%s` (no entrypoint).", symbol, typeName) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, RelationNoEntrypoint, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: symbol}}, meta) -} - -// invalidRelationOnTuplesetArgs names the parts of an invalid-relation-on-tupleset -// finding, which would otherwise be six same-typed positional arguments. -type invalidRelationOnTuplesetArgs struct { - symbol string - typeName string - typeDef string - relationName string - offendingRelation string - parent string - meta *Meta - lineIndex *int -} - -// newInvalidRelationOnTuplesetError reports a tupleset relation whose target does not -// exist on the referenced type. -func newInvalidRelationOnTuplesetError(lines []string, a invalidRelationOnTuplesetArgs) *ValidationError { - message := fmt.Sprintf("the `%s` relation definition on type `%s` is not valid: `%s` does not exist on `%s`, which is of type `%s`.", - a.offendingRelation, a.typeDef, a.offendingRelation, a.parent, a.typeName) - line, column := resolvePosition(lines, a.symbol, a.lineIndex, nil) - return newValidationError(message, InvalidRelationOnTupleset, a.symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: a.typeDef, Relation: a.relationName}}, a.meta) -} - -// invalidTypeRelationArgs names the parts of an invalid-relation-type finding. Its -// offendingType is the enclosing type the reference was written in, kept as metadata. -type invalidTypeRelationArgs struct { - symbol string - typeName string - relationName string - offendingRelation string - offendingType string - meta *Meta - lineIndex *int -} - -// newInvalidTypeRelationError reports a relation reference that is not valid for a type. -func newInvalidTypeRelationError(lines []string, a invalidTypeRelationArgs) *ValidationError { - message := fmt.Sprintf("`%s` is not a valid relation for `%s`.", a.offendingRelation, a.typeName) - line, column := resolvePosition(lines, a.symbol, a.lineIndex, nil) - return newValidationError(message, InvalidRelationType, a.symbol, line, column, scope{ - part: &fgaerrors.ErrRelation{ObjectType: a.typeName, Relation: a.relationName}, - offendingType: a.offendingType, - }, a.meta) -} - -// newInvalidTypeError reports an invalid type in an assignable-types list. Its column is -// resolved to the value side of the colon so it marks the type, not a relation key that -// shares its name. -func newInvalidTypeError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("`%s` is not a valid type.", symbol) - 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 - } - line, column := resolvePosition(lines, symbol, lineIndex, resolver) - return newValidationError(message, InvalidType, symbol, line, column, scope{part: &fgaerrors.ErrObjectType{ObjectType: symbol}}, meta) -} - -// newAssignableRelationMustHaveTypesError reports an assignable relation with no -// assignable type. -func newAssignableRelationMustHaveTypesError(lines []string, symbol string, lineIndex *int) *ValidationError { - message := fmt.Sprintf("the assignable relation '%s' must have at least one assignable type.", symbol) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, AssignableRelationsMustHaveType, symbol, line, column, scope{part: &fgaerrors.ErrRelation{Relation: symbol}}, nil) -} - -// newAssignableTypeWildcardRelationError reports a type restriction that carries both a -// wildcard and a relation. -func newAssignableTypeWildcardRelationError(lines []string, symbol, typeName, relation string, meta *Meta, lineIndex *int) *ValidationError { - 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) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, TypeRestrictionCannotHaveWildcardAndRelation, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}}, meta) -} - -// newInvalidRelationError reports a rewrite that names a relation the type does not -// define. The message names the missing relation only, as the reference's does. -func newInvalidRelationError(lines []string, symbol, typeName, relation string, - meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("the relation `%s` does not exist.", symbol) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, MissingDefinition, symbol, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relation}}, meta) -} - -// newInvalidSchemaVersionError 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 newInvalidSchemaVersionError(lines []string, symbol string, lineIndex *int) *ValidationError { - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(fmt.Sprintf("invalid schema %s", symbol), InvalidSchema, symbol, line, column, scope{}, nil) -} - -// newSchemaVersionUnsupportedError reports a recognized but retired schema version -// (e.g. "1.0"). -func newSchemaVersionUnsupportedError(lines []string, symbol string, lineIndex *int) *ValidationError { - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError("schema version no longer supported", SchemaVersionUnsupported, symbol, line, column, scope{}, nil) -} - -// newSchemaVersionRequiredError reports a model with no schema version. It names no part -// of the model, so it is about the model as a whole. -func newSchemaVersionRequiredError(lines []string, lineIndex *int) *ValidationError { - line, column := resolvePosition(lines, "", lineIndex, nil) - return newValidationError("schema version required", SchemaVersionRequired, "", line, column, scope{}, nil) -} - -// newMaximumOneDirectRelationshipError reports a relation with more than one direct -// relationship. -func newMaximumOneDirectRelationshipError(lines []string, symbol string, lineIndex *int) *ValidationError { - message := fmt.Sprintf("the relation '%s' can have at most one direct relationship.", symbol) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, DuplicatedError, symbol, line, column, scope{part: &fgaerrors.ErrRelation{Relation: symbol}}, nil) -} - -// newInvalidConditionNameInParameterError reports a reference to a condition that is not -// defined. It is scoped to the relation the condition is applied to, since the condition -// has no definition to point at. -func newInvalidConditionNameInParameterError(lines []string, symbol, typeName, relationName, conditionName string, - meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("`%s` is not a defined condition in the model.", conditionName) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, ConditionNotDefined, symbol, line, column, scope{part: &fgaerrors.ErrRelationCondition{ObjectType: typeName, Relation: relationName, Condition: conditionName}}, meta) -} - -// newUnusedConditionError reports a condition defined but never referenced. -func newUnusedConditionError(lines []string, symbol string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("`%s` condition is not used in the model.", symbol) - line, column := resolvePosition(lines, symbol, lineIndex, nil) - return newValidationError(message, ConditionNotUsed, symbol, line, column, scope{part: &fgaerrors.ErrCondition{Condition: symbol}}, meta) -} - -// newDifferentNestedConditionNameError reports a condition whose nested name property -// differs from its map key. It carries no position, matching the reference. -func newDifferentNestedConditionNameError(condition, nestedConditionName string) *ValidationError { - message := fmt.Sprintf("condition key is `%s` but nested name property is %s", condition, nestedConditionName) - return newValidationError(message, DifferentNestedConditionName, nestedConditionName, nil, nil, scope{part: &fgaerrors.ErrCondition{Condition: condition}}, nil) -} - -// newMultipleModulesInSingleFileError reports a file that would contain more than one -// module. It names no part of the model, so it is about the model as a whole. -func newMultipleModulesInSingleFileError(file string, modules []string) *ValidationError { - moduleList := strings.Join(modules, ", ") - message := fmt.Sprintf("file %s would contain multiple module definitions (%s) when transforming to DSL. "+ - "Only one module can be defined per file.", file, moduleList) - return newValidationError(message, MultipleModulesInFile, file, nil, nil, scope{}, nil) -} - -// newRedundantUnionMemberError reports a redundant member in a union operation. -func newRedundantUnionMemberError(lines []string, operation, relationName, typeName string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("Redundant operation '%s' found in union for relation '%s' of type '%s'", operation, relationName, typeName) - line, column := resolvePosition(lines, operation, lineIndex, nil) - return newValidationError(message, DuplicatedError, operation, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) -} - -// newImpossibleIntersectionError reports an intersection operation that cannot succeed. -func newImpossibleIntersectionError(lines []string, relationName, typeName string, conflictingTypes []string, meta *Meta, lineIndex *int) *ValidationError { - typeList := strings.Join(conflictingTypes, ", ") - message := fmt.Sprintf("Impossible intersection in relation '%s' of type '%s': conflicting types [%s]", relationName, typeName, typeList) - line, column := resolvePosition(lines, relationName, lineIndex, nil) - return newValidationError(message, InvalidRelationType, relationName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) -} - -// newEmptyDifferenceError reports a difference operation that results in an empty set. -func newEmptyDifferenceError(lines []string, relationName, typeName, operation string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("Empty difference operation in relation '%s' of type '%s': subtracting '%s' from itself", relationName, typeName, operation) - line, column := resolvePosition(lines, relationName, lineIndex, nil) - return newValidationError(message, RelationNoEntrypoint, relationName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: relationName}}, meta) -} - -// invalidWildcardUsageArgs names the parts of an invalid-wildcard finding. The wildcard -// is written in a relation of parentTypeName; typeName is the restriction it appears in. -type invalidWildcardUsageArgs struct { - typeName string - relationName string - parentTypeName string - reason string - meta *Meta - lineIndex *int -} - -// newInvalidWildcardUsageError reports a wildcard used where it is not allowed. -func newInvalidWildcardUsageError(lines []string, a invalidWildcardUsageArgs) *ValidationError { - message := fmt.Sprintf("Invalid wildcard usage for type '%s' in relation '%s' of type '%s': %s", - a.typeName, a.relationName, a.parentTypeName, a.reason) - line, column := resolvePosition(lines, a.typeName, a.lineIndex, nil) - return newValidationError(message, InvalidWildcardError, a.typeName, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: a.parentTypeName, Relation: a.relationName}}, a.meta) -} - -// newTuplesetNotDirectError reports a tupleset relation that does not allow direct -// assignment. -func newTuplesetNotDirectError(lines []string, tuplesetRelation, typeName, parentRelation string, meta *Meta, lineIndex *int) *ValidationError { - message := fmt.Sprintf("Tupleset relation '%s' on type '%s' must allow direct assignment (used in relation '%s')", - tuplesetRelation, typeName, parentRelation) - line, column := resolvePosition(lines, tuplesetRelation, lineIndex, nil) - return newValidationError(message, TuplesetNotDirect, tuplesetRelation, line, column, scope{part: &fgaerrors.ErrRelation{ObjectType: typeName, Relation: tuplesetRelation}}, meta) -} diff --git a/pkg/go/validation/error_builders_test.go b/pkg/go/validation/error_builders_test.go deleted file mode 100644 index 6f903342..00000000 --- a/pkg/go/validation/error_builders_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package validation - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -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 TestValidationErrors_AllFindings(t *testing.T) { - errs := NewValidationErrors(nil) - - // Initially no errors - assert.Empty(t, errs.AllFindings()) - - // Add an error - errs.Add(newInvalidNameError(nil, "test", "rule", nil, nil, nil)) - - findings := errs.AllFindings() - assert.Len(t, findings, 1) - assert.Contains(t, findings[0].Message, "test") -} - -func TestValidationErrors_HasErrorsAfterAdd(t *testing.T) { - errs := NewValidationErrors(nil) - - assert.False(t, errs.HasErrors()) - - errs.Add(newInvalidNameError(nil, "test", "rule", nil, nil, nil)) - - assert.True(t, errs.HasErrors()) -} - -func TestValidationErrors_CountAfterAdd(t *testing.T) { - errs := NewValidationErrors(nil) - - assert.Equal(t, 0, errs.Count()) - - errs.Add(newInvalidNameError(nil, "test1", "rule", nil, nil, nil)) - assert.Equal(t, 1, errs.Count()) - - errs.Add(newInvalidNameError(nil, "test2", "rule", nil, nil, nil)) - assert.Equal(t, 2, errs.Count()) -} - -func TestNewInvalidNameError(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) { - errs := NewValidationErrors(nil) - errs.Add(newInvalidNameError(nil, tt.symbol, tt.clause, tt.typeName, tt.meta, tt.lineIndex)) - - findings := errs.AllFindings() - assert.Len(t, findings, 1) - assert.Equal(t, tt.expectedMsg, findings[0].Message) - assert.Equal(t, tt.expectedType, findings[0].Metadata.ErrorType) - assert.Equal(t, tt.symbol, findings[0].Metadata.Symbol) - }) - } -} - -func TestNewInvalidConditionNameError(t *testing.T) { - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} - - err := newInvalidConditionNameError(nil, "bad name", "[a-zA-Z]+", meta, &lineIndex) - - assert.Equal(t, "condition 'bad name' does not match naming rule: '[a-zA-Z]+'.", err.Message) - assert.Equal(t, InvalidName, err.Metadata.ErrorType) - assert.Equal(t, "bad name", err.Metadata.Symbol) - assert.Equal(t, fgaerrors.ErrorKindCondition, err.Category) - assert.Equal(t, "bad name", err.Metadata.Condition) - assert.Empty(t, err.Metadata.Type) - - var scoped *fgaerrors.ErrCondition - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "bad name", scoped.Condition) -} - -func TestNewReservedTypeNameError(t *testing.T) { - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} - - err := newReservedTypeNameError(nil, "self", meta, &lineIndex) - - assert.Equal(t, "a type cannot be named 'self' or 'this'.", err.Message) - assert.Equal(t, ReservedTypeKeywords, err.Metadata.ErrorType) - assert.Equal(t, "self", err.Metadata.Symbol) - assert.Equal(t, "test.fga", err.File) -} - -func TestNewReservedRelationNameError(t *testing.T) { - lineIndex := 3 - meta := &Meta{File: "test.fga", Module: "test"} - - err := newReservedRelationNameError(nil, "this", "document", meta, &lineIndex) - - assert.Equal(t, "a relation cannot be named 'self' or 'this'.", err.Message) - assert.Equal(t, ReservedRelationKeywords, err.Metadata.ErrorType) - assert.Equal(t, "this", err.Metadata.Symbol) - assert.Equal(t, "document", err.Metadata.Type) -} - -func TestNewTupleUsersetRequiresDirectError(t *testing.T) { - lines := []string{ - "type document", - " relations", - " define viewer: user from parent", - " define admin: [user]", - } - lineIndex := 2 - meta := &Meta{File: "test.fga"} - - err := newTupleUsersetRequiresDirectError(lines, "user", "document", "viewer", meta, &lineIndex) - - assert.Equal(t, "`user` relation used inside from allows only direct relation.", err.Message) - assert.Equal(t, TuplesetNotDirect, err.Metadata.ErrorType) - assert.Equal(t, "user", err.Metadata.Symbol) -} - -func TestNewDuplicateTypeNameError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 10 - - err := newDuplicateTypeNameError(nil, "document", meta, &lineIndex) - - assert.Equal(t, "the type `document` is a duplicate.", err.Message) - assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) - assert.Equal(t, "document", err.Metadata.Symbol) -} - -func TestNewDuplicateTypeRestrictionError(t *testing.T) { - meta := &Meta{File: "test.fga"} - lineIndex := 5 - - err := newDuplicateTypeRestrictionError(nil, "user", "viewer", "document", meta, &lineIndex) - - assert.Equal(t, "the type restriction `user` is a duplicate in the relation `viewer`.", err.Message) - assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) - assert.Equal(t, "user", err.Metadata.Symbol) -} - -func TestNewNoEntryPointLoopError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 8 - - err := newNoEntryPointLoopError(nil, "viewer", "document", meta, &lineIndex) - - assert.Equal(t, "`viewer` is an impossible relation for `document` (potential loop).", err.Message) - assert.Equal(t, RelationNoEntrypoint, err.Metadata.ErrorType) - assert.Equal(t, "viewer", err.Metadata.Symbol) -} - -func TestNewNoEntryPointError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 12 - - err := newNoEntryPointError(nil, "viewer", "document", meta, &lineIndex) - - assert.Equal(t, "`viewer` is an impossible relation for `document` (no entrypoint).", err.Message) - assert.Equal(t, RelationNoEntrypoint, err.Metadata.ErrorType) - assert.Equal(t, "viewer", err.Metadata.Symbol) -} - -func TestNewInvalidTypeError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 3 - - err := newInvalidTypeError(nil, "unknown_type", meta, &lineIndex) - - assert.Equal(t, "`unknown_type` is not a valid type.", err.Message) - assert.Equal(t, InvalidType, err.Metadata.ErrorType) - assert.Equal(t, "unknown_type", err.Metadata.Symbol) -} - -func TestNewAssignableRelationMustHaveTypesError(t *testing.T) { - lineIndex := 6 - - err := newAssignableRelationMustHaveTypesError(nil, "viewer", &lineIndex) - - assert.Equal(t, "the assignable relation 'viewer' must have at least one assignable type.", err.Message) - assert.Equal(t, AssignableRelationsMustHaveType, err.Metadata.ErrorType) - assert.Equal(t, "viewer", err.Metadata.Symbol) -} - -func TestNewInvalidRelationError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 4 - - err := newInvalidRelationError(nil, "unknown", "document", "relation", meta, &lineIndex) - - assert.Equal(t, "the relation `unknown` does not exist.", err.Message) - assert.Equal(t, MissingDefinition, err.Metadata.ErrorType) - assert.Equal(t, "unknown", err.Metadata.Symbol) -} - -func TestNewSchemaVersionRequiredError(t *testing.T) { - lineIndex := 0 - - err := newSchemaVersionRequiredError(nil, &lineIndex) - - assert.Equal(t, "schema version required", err.Message) - assert.Equal(t, SchemaVersionRequired, err.Metadata.ErrorType) -} - -func TestNewInvalidSchemaVersionError(t *testing.T) { - lineIndex := 1 - - err := newInvalidSchemaVersionError(nil, "2.0", &lineIndex) - - assert.Equal(t, "invalid schema 2.0", err.Message) - assert.Equal(t, InvalidSchema, err.Metadata.ErrorType) - assert.Equal(t, "2.0", err.Metadata.Symbol) -} - -func TestNewSchemaVersionUnsupportedError(t *testing.T) { - lineIndex := 1 - - err := newSchemaVersionUnsupportedError(nil, "1.0", &lineIndex) - - assert.Equal(t, "schema version no longer supported", err.Message) - assert.Equal(t, SchemaVersionUnsupported, err.Metadata.ErrorType) - assert.Equal(t, "1.0", err.Metadata.Symbol) -} - -func TestNewUnusedConditionError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 15 - - err := newUnusedConditionError(nil, "unused_condition", meta, &lineIndex) - - assert.Equal(t, "`unused_condition` condition is not used in the model.", err.Message) - assert.Equal(t, ConditionNotUsed, err.Metadata.ErrorType) - assert.Equal(t, "unused_condition", err.Metadata.Symbol) -} - -func TestNewDifferentNestedConditionNameError(t *testing.T) { - err := newDifferentNestedConditionNameError("condition1", "condition2") - - assert.Equal(t, "condition key is `condition1` but nested name property is condition2", err.Message) - assert.Equal(t, DifferentNestedConditionName, err.Metadata.ErrorType) - assert.Equal(t, "condition2", err.Metadata.Symbol) -} - -func TestNewMultipleModulesInSingleFileError(t *testing.T) { - modules := []string{"module1", "module2", "module3"} - - err := newMultipleModulesInSingleFileError("test.fga", modules) - - assert.Equal(t, "file test.fga would contain multiple module definitions (module1, module2, module3) "+ - "when transforming to DSL. Only one module can be defined per file.", err.Message) - assert.Equal(t, MultipleModulesInFile, err.Metadata.ErrorType) - assert.Equal(t, "test.fga", err.Metadata.Symbol) -} - -func TestLineAndColumnResolution(t *testing.T) { - lines := []string{ - "model", - " schema 1.1", - "type document", - " relations", - " define viewer: [user]", - } - lineIndex := 4 - - err := newInvalidNameError(lines, "viewer", "rule", nil, nil, &lineIndex) - - // Check line information - assert.NotNil(t, err.Line) - assert.Equal(t, 4, err.Line.Start) - assert.Equal(t, 4, err.Line.End) - - // Check column information (should find "viewer" in the line) - assert.NotNil(t, err.Column) - line := lines[4] - expectedStart := strings.Index(line, "viewer") - assert.Equal(t, expectedStart, err.Column.Start) - assert.Equal(t, expectedStart+len("viewer"), err.Column.End) -} - -func TestCustomResolver(t *testing.T) { - lines := []string{ - "type document", - " relations", - " define viewer: user from parent", - } - lineIndex := 2 - meta := &Meta{File: "test.fga"} - - err := newTupleUsersetRequiresDirectError(lines, "user", "document", "viewer", meta, &lineIndex) - - // The custom resolver should position the error after the "from" keyword - assert.NotNil(t, err.Column) - line := lines[2] - fromIndex := strings.Index(line, "from") - expectedStart := fromIndex + len("from") + strings.Index(line[fromIndex+len("from"):], "user") - assert.Equal(t, expectedStart, err.Column.Start) -} - -func TestNewUndefinedRelationError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 4 - - err := newUndefinedRelationError(nil, "viewer", "document", "can_view", "folder", meta, &lineIndex) - - assert.Equal(t, "Relation 'viewer' is not defined on type 'document' (referenced in relation 'can_view' of type 'folder')", err.Message) - assert.Equal(t, UndefinedRelation, err.Metadata.ErrorType) - assert.Equal(t, "viewer", err.Metadata.Symbol) - assert.Equal(t, "document", err.Metadata.Type) - assert.Equal(t, "viewer", err.Metadata.Relation) -} - -func TestNewDuplicateRelationshipDefinitionError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 7 - - err := newDuplicateRelationshipDefinitionError(nil, "viewer", meta, &lineIndex) - - assert.Equal(t, "the relation 'viewer' is defined more than once.", err.Message) - assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) - assert.Equal(t, "viewer", err.Metadata.Symbol) - assert.Equal(t, "viewer", err.Metadata.Relation) -} - -func TestNewAssignableTypeWildcardRelationError(t *testing.T) { - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 9 - - err := newAssignableTypeWildcardRelationError(nil, "user", "document", "viewer", meta, &lineIndex) - - assert.Equal(t, "the type restriction 'user' on relation 'viewer' of type 'document' is not allowed to have both a wildcard and a relation.", err.Message) - assert.Equal(t, TypeRestrictionCannotHaveWildcardAndRelation, err.Metadata.ErrorType) - assert.Equal(t, "user", err.Metadata.Symbol) - assert.Equal(t, "document", err.Metadata.Type) - assert.Equal(t, "viewer", err.Metadata.Relation) -} - -func TestNewMaximumOneDirectRelationshipError(t *testing.T) { - lineIndex := 11 - - err := newMaximumOneDirectRelationshipError(nil, "viewer", &lineIndex) - - assert.Equal(t, "the relation 'viewer' can have at most one direct relationship.", err.Message) - assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) - assert.Equal(t, "viewer", err.Metadata.Symbol) - assert.Equal(t, "viewer", err.Metadata.Relation) -} diff --git a/pkg/go/validation/error_construction.go b/pkg/go/validation/error_construction.go deleted file mode 100644 index 8a782cd8..00000000 --- a/pkg/go/validation/error_construction.go +++ /dev/null @@ -1,150 +0,0 @@ -package validation - -import ( - "strings" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// 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') -} - -// scope is what a raise site knows that a finding's code cannot work out on its own: -// which part of the model is at fault, and the enclosing type for the metadata. -type scope struct { - // part names the part of the model at fault. The raise site builds it, because - // the code alone does not say which part: duplicated-error is raised about a type - // from one place and a relation from another, and invalid-name about all three. - // The sentinel is filled in from the code's table entry, so at this point it - // wraps nothing. - part fgaerrors.ModelError - - // offendingType is the enclosing type a finding about another type was written - // in, matching JS's wire field of the same name. Metadata only: none of the - // scope types has a slot for it. - offendingType string -} - -// newValidationError builds the finding a raise site describes: the code decides the -// severity and the sentinel it wraps, the scope decides which part of the model it -// names, and both the category and the metadata are read back off that. The line and -// column arguments are the already-resolved position, nil when the raise site gave none. -func newValidationError(message string, errorType ValidationErrorType, symbol string, - line, column *Range, errorScope scope, meta *Meta) *ValidationError { - part := errorScope.part - if part == nil { - // A raise site that named nothing. Treat it as being about the model as a - // whole, which is what a code with no scope means. - part = &fgaerrors.ErrModel{} - } - - entry := lookupErrorInfo(errorType) - partScope := part.Scope() - - metadata := &ErrorMetadata{ - Symbol: symbol, - ErrorType: errorType, - OffendingType: errorScope.offendingType, - Type: partScope.ObjectType, - Relation: partScope.Relation, - Condition: partScope.Condition, - } - - if meta != nil { - // Module goes in the metadata, file on the error itself, matching the - // JS implementation. - metadata.Module = meta.Module - } - - validationErr := &ValidationError{ - Message: message, - Severity: entry.Severity, - Category: part.Kind(), - Line: line, - Column: column, - Metadata: metadata, - - // A code missing from the table has no sentinel, so there is nothing for - // errors.Is to match and this is nil. The category and metadata above still - // report what the raise site named. - Cause: fgaerrors.WithSentinel(part, entry.Cause), - } - - if meta != nil { - validationErr.File = meta.File - } - - return validationErr -} - -// resolvePosition resolves the line and column a finding points at, both nil when the -// raise site gave no line or the line is outside the source. -func resolvePosition(lines []string, symbol string, lineIndex *int, - customResolver ErrorCustomResolver) (line, column *Range) { - if lineIndex == nil || *lineIndex < 0 || *lineIndex >= len(lines) { - return nil, nil - } - - line = &Range{Start: *lineIndex, End: *lineIndex} - - // Find symbol position in line for column calculation, matching on word - // boundaries as the reference does. - rawLine := lines[*lineIndex] - symbolPos := wordIndex(rawLine, symbol) - - if customResolver != nil { - symbolPos = customResolver(symbolPos, rawLine, symbol) - } - - if symbolPos >= 0 { - column = &Range{ - Start: symbolPos, - End: symbolPos + len(symbol), - } - } - - return line, column -} diff --git a/pkg/go/validation/error_info.go b/pkg/go/validation/error_info.go deleted file mode 100644 index 55e4206a..00000000 --- a/pkg/go/validation/error_info.go +++ /dev/null @@ -1,166 +0,0 @@ -package validation - -import ( - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// errorInfo is what a code implies beyond its message: its severity and the -// sentinel a caller matches with errors.Is. -type errorInfo struct { - Severity fgaerrors.Severity - Cause error - - // Critical marks a finding that invalidates the model as a whole rather than - // one part of it, so a consumer may stop at the first one. Critical implies - // blocking; TestCriticalImpliesBlocking enforces it. - Critical bool -} - -// errorInfoByType maps every code the validator emits to its severity, cause and -// criticality. It is the only place those are decided, so a code cannot mean one -// thing at a raise site and another in a report. -// -// Which part of the model a finding is about is not here, because it does not follow -// from the code. DuplicatedError covers a duplicate type and a duplicate type -// restriction; InvalidName covers a type, a relation and a condition. The raise site -// states it by building the cause it passes in scope. -// -// Every emitted code must appear here; TestErrorInfoCoversEveryEmittedErrorType -// enforces it, and declared-but-unemitted codes go in unemittedErrorTypes in -// error_info_test.go instead. -var errorInfoByType = map[ValidationErrorType]errorInfo{ - // Schema. - InvalidSchema: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidSchemaVersion, - Critical: true, - }, - SchemaVersionUnsupported: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrSchemaVersionUnsupported, - }, - SchemaVersionRequired: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrSchemaVersionRequired, - }, - - // Naming. - InvalidName: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidName, - }, - ReservedTypeKeywords: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrReservedKeywords, - }, - ReservedRelationKeywords: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrReservedKeywords, - }, - - // Duplicates. - DuplicatedError: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrDuplicateDefinition, - Critical: true, - }, - - // Undefined references. - UndefinedType: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrObjectTypeUndefined, - Critical: true, - }, - UndefinedRelation: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrRelationUndefined, - Critical: true, - }, - MissingDefinition: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrRelationUndefined, - }, - - // Types and type restrictions. - InvalidType: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidType, - }, - InvalidRelationType: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidRelationType, - Critical: true, - }, - AssignableRelationsMustHaveType: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrDirectlyAssignableRelation, - }, - - // Tuplesets. - InvalidRelationOnTupleset: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidRelationOnTupleset, - }, - TuplesetNotDirect: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidRelationOnTuplesetNotDirect, - }, - - // Entrypoints. - RelationNoEntrypoint: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrNoEntrypoints, - Critical: true, - }, - - // Wildcards. - InvalidWildcardError: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidWildcard, - }, - TypeRestrictionCannotHaveWildcardAndRelation: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrInvalidWildcard, - }, - - // Conditions. - ConditionNotDefined: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrConditionUndefined, - }, - ConditionNotUsed: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrConditionUnReferenced, - }, - DifferentNestedConditionName: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrConditionNameMismatch, - }, - - // Modules. - MultipleModulesInFile: { - Severity: fgaerrors.SeverityError, - Cause: fgaerrors.ErrMultipleModulesInFile, - Critical: true, - }, -} - -// isCriticalErrorType reports whether a code invalidates the model as a whole. -// Criticality is a field on the errorInfo entry, so a code cannot be critical and -// non-blocking at once. Unknown codes are blocking but not critical. -func isCriticalErrorType(errorType ValidationErrorType) bool { - return lookupErrorInfo(errorType).Critical -} - -// lookupErrorInfo returns the entry for a code. -// -// Unknown and unemitted codes fall back to a blocking error with no cause, so a -// code missing from the table cannot downgrade a finding to non-blocking. -func lookupErrorInfo(errorType ValidationErrorType) errorInfo { - if entry, ok := errorInfoByType[errorType]; ok { - return entry - } - return errorInfo{ - Severity: fgaerrors.SeverityError, - } -} diff --git a/pkg/go/validation/error_info_integration_test.go b/pkg/go/validation/error_info_integration_test.go deleted file mode 100644 index 061d65a7..00000000 --- a/pkg/go/validation/error_info_integration_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package validation - -import ( - "errors" - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" - "github.com/openfga/language/pkg/go/transformer" -) - -// modelFromDSL parses a DSL string, failing the test if it does not parse: these -// tests are about semantic validation, so a syntax error in a fixture is a bug in -// the test rather than a result. -func modelFromDSL(t *testing.T, dsl string) *openfgav1.AuthorizationModel { - t.Helper() - - model, err := transformer.TransformDSLToProto(dsl) - require.NoError(t, err, "test DSL must parse; this test is about semantic validation") - - return model -} - -// validateDSL runs the full validation path on a DSL string, as a consumer would, -// and recovers the collection behind the returned error. -func validateDSL(t *testing.T, dsl string) *ValidationErrors { - t.Helper() - - return findingsFrom(ValidateDSL(modelFromDSL(t, dsl), dsl, DefaultEngineOptions())) -} - -// TestErrorsIsThroughValidation checks errors.Is against findings from real -// validation, so a caller can identify what went wrong without matching message -// text. -func TestErrorsIsThroughValidation(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - dsl string - wantSentinel error - wantCategory fgaerrors.ModelErrorKind - wantScope func(t *testing.T, err error) - }{ - "undefined type in restriction": { - dsl: `model - schema 1.1 -type document - relations - define viewer: [user] -`, - wantSentinel: fgaerrors.ErrInvalidType, - wantCategory: fgaerrors.ErrorKindObjectType, - wantScope: func(t *testing.T, err error) { - t.Helper() - - var scoped *fgaerrors.ErrObjectType - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "user", scoped.ObjectType) - }, - }, - "relation with no entrypoint": { - dsl: `model - schema 1.1 -type user -type document - relations - define viewer: writer - define writer: viewer -`, - wantSentinel: fgaerrors.ErrNoEntrypoints, - wantCategory: fgaerrors.ErrorKindRelation, - wantScope: func(t *testing.T, err error) { - t.Helper() - - var scoped *fgaerrors.ErrRelation - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "document", scoped.ObjectType) - assert.NotEmpty(t, scoped.Relation) - }, - }, - "duplicate type": { - dsl: `model - schema 1.1 -type user -type document -type document -`, - wantSentinel: fgaerrors.ErrDuplicateDefinition, - wantCategory: fgaerrors.ErrorKindObjectType, - wantScope: func(t *testing.T, err error) { - t.Helper() - - // An ErrObjectType has no Relation field, so a duplicate type - // cannot arrive carrying one. - var scoped *fgaerrors.ErrObjectType - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "document", scoped.ObjectType) - }, - }, - "condition defined but unused": { - dsl: `model - schema 1.1 -type user -type document - relations - define viewer: [user] - -condition inRegion(x: string) { - x == "eu" -} -`, - wantSentinel: fgaerrors.ErrConditionUnReferenced, - wantCategory: fgaerrors.ErrorKindCondition, - wantScope: func(t *testing.T, err error) { - t.Helper() - - // A condition definition is not scoped to a type, and - // ErrCondition has no field for one. - var scoped *fgaerrors.ErrCondition - require.ErrorAs(t, err, &scoped) - assert.Equal(t, "inRegion", scoped.Condition) - }, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - validationErrors := validateDSL(t, test.dsl) - require.NotNil(t, validationErrors) - require.True(t, validationErrors.HasErrors(), "expected this model to fail validation") - - // Every finding, so a severity this test does not expect fails on the - // severity assertion below rather than by going missing here. - var matched *ValidationError - for _, candidate := range validationErrors.AllFindings() { - if errors.Is(candidate, test.wantSentinel) { - matched = candidate - - break - } - } - - require.NotNilf(t, matched, - "no finding matched %v via errors.Is; got %v", test.wantSentinel, validationErrors.Error()) - - assert.Equal(t, test.wantCategory, matched.Category) - assert.Equal(t, fgaerrors.SeverityError, matched.Severity) - assert.True(t, matched.Blocks()) - - require.Error(t, matched.Unwrap(), "errors.As must have a scoped cause to reach") - test.wantScope(t, error(matched)) - }) - } -} - -// findingScope reads the scope off a finding the way a consumer does: errors.As from -// the outer error, rather than off the Cause field. A finding carrying no scoped -// cause yields an empty scope. -func findingScope(finding *ValidationError) fgaerrors.ModelErrorScope { - var modelErr fgaerrors.ModelError - if !errors.As(error(finding), &modelErr) { - return fgaerrors.ModelErrorScope{} - } - - return modelErr.Scope() -} - -// TestMetadataIsDerivedFromCause checks the serialised metadata and the errors.As -// payload describe the same scope, so the two cannot drift. -func TestMetadataIsDerivedFromCause(t *testing.T) { - t.Parallel() - - validationErrors := validateDSL(t, `model - schema 1.1 -type user -type document - relations - define viewer: writer - define writer: viewer -`) - require.True(t, validationErrors.HasErrors()) - - checked := 0 - - for _, validationErr := range validationErrors.AllFindings() { - if validationErr.Unwrap() == nil { - continue - } - - causeScope := findingScope(validationErr) - - require.NotNil(t, validationErr.Metadata) - assert.Equal(t, causeScope.ObjectType, validationErr.Metadata.Type, - "metadata type must match the cause it was derived from") - assert.Equal(t, causeScope.Relation, validationErr.Metadata.Relation) - assert.Equal(t, causeScope.Condition, validationErr.Metadata.Condition) - - checked++ - } - - assert.Positive(t, checked, "no finding carried a scoped cause; the derivation was not exercised") -} - -// TestEverySemanticFindingCarriesErrorInfo sweeps a range of broken models and -// asserts no finding escapes without severity, category and a matchable cause. -// A gap here means some code path bypasses the table. -func TestEverySemanticFindingCarriesErrorInfo(t *testing.T) { - t.Parallel() - - models := []string{ - `model - schema 1.1 -type document - relations - define viewer: [user] -`, - `model - schema 1.1 -type user -type document -type document -`, - `model - schema 1.1 -type user -type document - relations - define viewer: writer - define writer: viewer -`, - `model - schema 1.1 -type user -type document - relations - define viewer: [user] - -condition inRegion(x: string) { - x == "eu" -} -`, - `model - schema 1.1 -type user -type document - relations - define parent: [document] - define viewer: viewer from parent -`, - } - - total := 0 - - for index, dsl := range models { - model, err := transformer.TransformDSLToProto(dsl) - - // Not skipped: a model that stops parsing drops silently out of the sweep, - // and the total below would still pass on the models that remain. - require.NoErrorf(t, err, "model %d no longer parses", index) - - validationErrors := findingsFrom(ValidateDSL(model, dsl, DefaultEngineOptions())) - - for _, validationErr := range validationErrors.AllFindings() { - total++ - - require.NotNilf(t, validationErr.Metadata, "model %d: finding without metadata", index) - - errorType := validationErr.Metadata.ErrorType - - assert.NotEmptyf(t, validationErr.Severity, - "model %d: %q has no severity", index, errorType) - - // The category comes off the cause the raise site built, so a finding - // with none has a raise site that named no part of the model. - assert.Truef(t, validationErr.Category.IsValid(), - "model %d: %q has no category, so its raise site named no part of the model", - index, errorType) - - if _, classified := errorInfoByType[errorType]; classified { - require.Errorf(t, validationErr.Unwrap(), - "model %d: %q is in the errorInfoByType but carries no cause", index, errorType) - } - } - } - - assert.Positive(t, total, "no findings produced; this test asserted nothing") -} - -// TestNonBlockingTableEntryReachesTheCaller closes the gap the other severity tests -// leave: they assert what errorInfoByType holds, or build findings by hand, and every -// entry is SeverityError today, so nothing follows a non-blocking severity from the -// table through a constructor and out of an entry point. This downgrades one entry and -// does exactly that. -// -// It must not call t.Parallel: it mutates errorInfoByType, and Go runs a sequential -// test only with other sequential tests. -func TestNonBlockingTableEntryReachesTheCaller(t *testing.T) { - original := errorInfoByType[InvalidName] - downgraded := original - downgraded.Severity = fgaerrors.SeverityWarning - errorInfoByType[InvalidName] = downgraded - - t.Cleanup(func() { errorInfoByType[InvalidName] = original }) - - // A name the DSL parser would reject, so the model is built as the proto a JSON - // caller would supply. - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{{Type: "Bad Type Name"}}, - } - - require.NoError(t, ValidateModelJSON(model), - "a model whose only finding is a warning is valid, so the entry point returns nil") - - engine := NewValidationEngine(model, "") - collection := engine.RunAllValidations(DefaultEngineOptions()) - - require.Equal(t, 1, collection.CountAll(), - "this model must raise exactly one finding, or the counts below are ambiguous") - assert.Equal(t, 0, collection.Count()) - assert.False(t, collection.HasErrors()) - assert.True(t, collection.HasFindings()) - assert.Empty(t, collection.GetErrors()) - require.NoError(t, collection.ErrorOrNil()) - - finding := collection.AllFindings()[0] - assert.Equal(t, fgaerrors.SeverityWarning, finding.Severity, "the severity came from the table") - assert.False(t, finding.Blocks()) - - // Severity is independent of the cause: a warning still carries its sentinel and - // its scope. - require.ErrorIs(t, error(finding), fgaerrors.ErrInvalidName) - - var scoped *fgaerrors.ErrObjectType - require.ErrorAs(t, error(finding), &scoped) - assert.Equal(t, "Bad Type Name", scoped.ObjectType) - - summary := engine.GetValidationSummary() - assert.Equal(t, 0, summary.TotalErrors) - assert.Equal(t, 1, summary.TotalFindings) - assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityWarning]) - assert.False(t, summary.HasCriticalErrors) - - report := CreateValidationReport(model, "", DefaultEngineOptions()) - assert.True(t, report.IsValid(), "a warning does not invalidate the model") - assert.Len(t, report.GetErrorsByType(InvalidName), 1, - "GetErrorsByType names a code, so it returns the finding whatever its severity") -} diff --git a/pkg/go/validation/error_info_test.go b/pkg/go/validation/error_info_test.go deleted file mode 100644 index 7b5900f7..00000000 --- a/pkg/go/validation/error_info_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package validation - -import ( - "go/ast" - "go/parser" - "go/token" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// unemittedErrorTypes are declared ValidationErrorType values that no validation -// produces. The other side is read out of the source by emittedErrorTypes below, and -// TestEveryErrorTypeIsClassified requires every declared code to be in one or the -// other. -// -// They are kept rather than deleted because each has a published documentation -// page, and because SelfError and InvalidSyntax are equally unemitted in -// pkg/js/errors.ts. A cycle with no entrypoint surfaces as RelationNoEntrypoint, -// leaving CyclicError and CyclicRelation nothing to report. InvalidSchemaVersion is -// unreachable because newInvalidSchemaVersionError emits InvalidSchema, which is what -// the shared corpus expects. -// -// None get an errorInfoByType entry, so lookupErrorInfo treats them as blocking -// with no cause. Anything that starts emitting one must add it to that table in the -// same change. -var unemittedErrorTypes = map[ValidationErrorType]struct{}{ - SelfError: {}, - InvalidSyntax: {}, - CyclicError: {}, - CyclicRelation: {}, - InvalidSchemaVersion: {}, -} - -// allErrorTypes lists every declared ValidationErrorType. A Go const block of a -// string type cannot be enumerated at runtime, so exhaustiveness checks need it -// written out. -// -// Keep in sync with the const block in errors.go. TestAllErrorTypesIsComplete reads -// that block and fails if the two disagree. -var allErrorTypes = []ValidationErrorType{ - SchemaVersionRequired, - SchemaVersionUnsupported, - ReservedTypeKeywords, - ReservedRelationKeywords, - SelfError, - InvalidName, - MissingDefinition, - InvalidRelationType, - InvalidRelationOnTupleset, - InvalidType, - RelationNoEntrypoint, - TuplesetNotDirect, - DuplicatedError, - UndefinedType, - UndefinedRelation, - CyclicError, - InvalidWildcardError, - AssignableRelationsMustHaveType, - InvalidSchema, - InvalidSyntax, - TypeRestrictionCannotHaveWildcardAndRelation, - ConditionNotDefined, - ConditionNotUsed, - DifferentNestedConditionName, - MultipleModulesInFile, - CyclicRelation, - InvalidSchemaVersion, -} - -// emittedErrorTypes parses this package's non-test sources and returns the name of -// every ValidationErrorType passed as the errorType argument of a newValidationError -// call. It reads the source rather than a hand-written list, which would go stale in -// the same edit that leaves a code out of the table. -func emittedErrorTypes(t *testing.T) map[string]string { - t.Helper() - - entries, err := os.ReadDir(".") - require.NoError(t, err) - - emitted := make(map[string]string) - fileSet := token.NewFileSet() - - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - - file, err := parser.ParseFile(fileSet, name, nil, parser.SkipObjectResolution) - require.NoError(t, err, "parsing %s", name) - - ast.Inspect(file, func(node ast.Node) bool { - call, ok := node.(*ast.CallExpr) - if !ok { - return true - } - - // Every constructor names the finding's code as the second argument to - // newValidationError(message, , ...). That is the one place a - // code reaches a finding, so reading it here reports exactly the set a - // raise site can produce. - identFun, ok := call.Fun.(*ast.Ident) - if !ok || identFun.Name != "newValidationError" || len(call.Args) < 2 { - return true - } - - identifier, ok := call.Args[1].(*ast.Ident) - if !ok { - // A non-identifier errorType means the emitted set can't be - // determined statically, and this test would silently under-report. - t.Errorf("%s: newValidationError called with a non-constant errorType at %s; "+ - "emittedErrorTypes can no longer see what this emits", - name, fileSet.Position(call.Args[1].Pos())) - - return true - } - - emitted[identifier.Name] = fileSet.Position(call.Pos()).String() - - return true - }) - } - - return emitted -} - -// TestErrorInfoCoversEveryEmittedErrorType checks every code a constructor can -// emit has a table entry, so any finding that reaches a caller has a cause to -// match. It fails when a new constructor is added without one. -func TestErrorInfoCoversEveryEmittedErrorType(t *testing.T) { - t.Parallel() - - emitted := emittedErrorTypes(t) - require.NotEmpty(t, emitted, "found no newValidationError calls — the AST walk is broken, not the errorInfoByType") - - // Names, because the AST gives us identifiers and the table is keyed by value. - classifiedNames := make(map[string]struct{}, len(errorInfoByType)) - for errorType := range errorInfoByType { - classifiedNames[errorTypeConstantName(t, errorType)] = struct{}{} - } - - for name, position := range emitted { - if _, ok := classifiedNames[name]; !ok { - t.Errorf("%s is emitted at %s but has no errorInfoByType entry: "+ - "callers cannot match its cause with errors.Is", name, position) - } - } -} - -// TestErrorInfoHasNoUnemittedEntries checks the other direction: an entry for a code -// nothing raises hides that the code is dead. -func TestErrorInfoHasNoUnemittedEntries(t *testing.T) { - t.Parallel() - - emitted := emittedErrorTypes(t) - - for errorType := range errorInfoByType { - name := errorTypeConstantName(t, errorType) - if _, ok := emitted[name]; !ok { - t.Errorf("errorInfoByType has an entry for %s (%q) but nothing emits it — "+ - "either wire up the constructor or move it to unemittedErrorTypes", - name, errorType) - } - } -} - -// TestEveryErrorTypeIsClassified checks a newly declared error type cannot sit in -// neither map. Adding a constant and forgetting the table passes every other test -// in this file. -func TestEveryErrorTypeIsClassified(t *testing.T) { - t.Parallel() - - for _, errorType := range allErrorTypes { - _, inErrorInfo := errorInfoByType[errorType] - _, unemitted := unemittedErrorTypes[errorType] - - assert.Truef(t, inErrorInfo || unemitted, - "%q appears in neither errorInfoByType nor unemittedErrorTypes; "+ - "classify it as one or the other", errorType) - assert.Falsef(t, inErrorInfo && unemitted, - "%q is in both errorInfoByType and unemittedErrorTypes", errorType) - } -} - -// TestAllErrorTypesIsComplete checks the hand-written allErrorTypes list the two -// tests above depend on, by reading the const block it mirrors. -func TestAllErrorTypesIsComplete(t *testing.T) { - t.Parallel() - - declared := declaredErrorTypeValues(t) - - listed := make(map[ValidationErrorType]struct{}, len(allErrorTypes)) - for _, errorType := range allErrorTypes { - _, duplicate := listed[errorType] - assert.Falsef(t, duplicate, "%q is listed twice in allErrorTypes", errorType) - listed[errorType] = struct{}{} - } - - for name, value := range declared { - _, ok := listed[value] - assert.Truef(t, ok, "%s (%q) is declared in errors.go but missing from allErrorTypes", name, value) - } - - assert.Len(t, allErrorTypes, len(declared), - "allErrorTypes has %d entries but %d ValidationErrorType constants are declared", - len(allErrorTypes), len(declared)) -} - -// TestErrorInfoEntriesAreWellFormed checks each entry says something usable: a -// severity that exists and a non-nil cause. Which part of the model a code is about -// is not in the table, so it is checked on the findings themselves, in -// TestEverySemanticFindingCarriesErrorInfo. -func TestErrorInfoEntriesAreWellFormed(t *testing.T) { - t.Parallel() - - validSeverities := map[fgaerrors.Severity]struct{}{ - fgaerrors.SeverityError: {}, - fgaerrors.SeverityWarning: {}, - fgaerrors.SeverityAdvisory: {}, - } - - for errorType, entry := range errorInfoByType { - t.Run(string(errorType), func(t *testing.T) { - t.Parallel() - - _, ok := validSeverities[entry.Severity] - assert.Truef(t, ok, "severity %q is not one of error/warning/advisory", entry.Severity) - - assert.Error(t, entry.Cause, "no cause: errors.Is has nothing to match against") - }) - } -} - -// TestEveryEntryBlocks pins what the validation entry points currently rely on: -// every classified code blocks, so Count equals CountAll and a model with any -// finding at all returns non-nil from ValidateDSL. -// -// This is a tripwire rather than a rule. The first non-blocking entry is a -// deliberate change, and it needs ValidationErrors.ErrorOrNil settled in the same -// edit: a collection holding only warnings answers nil there, so that finding never -// reaches a caller through the entry points at all. Either route it through -// CreateValidationReport or change what the entry points return, then update this -// test. -func TestEveryEntryBlocks(t *testing.T) { - t.Parallel() - - for errorType, entry := range errorInfoByType { - assert.Truef(t, entry.Severity.Blocks(), - "%q is classified %s, the first non-blocking code in the table: a model whose "+ - "only finding is this one is valid as far as ValidateDSL is concerned", - errorType, entry.Severity) - } -} - -// TestLookupErrorInfoFallsBackToBlocking checks an unclassified finding still fails -// validation. Downgrading it to advisory would let an invalid model through. -func TestLookupErrorInfoFallsBackToBlocking(t *testing.T) { - t.Parallel() - - entry := lookupErrorInfo(ValidationErrorType("no-such-error-type")) - - assert.Equal(t, fgaerrors.SeverityError, entry.Severity) - assert.True(t, entry.Severity.Blocks(), "an unknown error type must still block validation") - assert.NoError(t, entry.Cause, "an unknown error type has no cause to report") -} - -// TestEveryErrorTypeHasDocumentation keeps the slugs and docs/validation/model in -// step. The slug is what a user sees, so one with no page is a dead end. -func TestEveryErrorTypeHasDocumentation(t *testing.T) { - t.Parallel() - - docsDir := filepath.Join("..", "..", "..", "docs", "validation", "model") - if _, err := os.Stat(docsDir); os.IsNotExist(err) { - t.Skipf("docs directory not present at %s", docsDir) - } - - for _, errorType := range allErrorTypes { - page := filepath.Join(docsDir, string(errorType)+".md") - _, err := os.Stat(page) - assert.NoErrorf(t, err, "%q has no documentation page at %s", errorType, page) - } -} - -// errorTypeConstantName maps a slug back to its Go constant name, so failures name -// the identifier to edit rather than the string. -func errorTypeConstantName(t *testing.T, errorType ValidationErrorType) string { - t.Helper() - - for name, value := range declaredErrorTypeValues(t) { - if value == errorType { - return name - } - } - - t.Fatalf("%q is not a declared ValidationErrorType constant", errorType) - - return "" -} - -// declaredErrorTypeValues parses errors.go and returns every declared -// ValidationErrorType constant as name → value. -func declaredErrorTypeValues(t *testing.T) map[string]ValidationErrorType { - t.Helper() - - fileSet := token.NewFileSet() - file, err := parser.ParseFile(fileSet, "errors.go", nil, parser.SkipObjectResolution) - require.NoError(t, err) - - declared := make(map[string]ValidationErrorType) - - for _, decl := range file.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.CONST { - continue - } - - for _, spec := range genDecl.Specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if !ok { - continue - } - - typeIdent, ok := valueSpec.Type.(*ast.Ident) - if !ok || typeIdent.Name != "ValidationErrorType" { - continue - } - - for i, name := range valueSpec.Names { - require.Lessf(t, i, len(valueSpec.Values), "%s has no value", name.Name) - - literal, ok := valueSpec.Values[i].(*ast.BasicLit) - require.Truef(t, ok, "%s is not assigned a string literal", name.Name) - - value, err := strconv.Unquote(literal.Value) - require.NoError(t, err) - - declared[name.Name] = ValidationErrorType(value) - } - } - } - - require.NotEmpty(t, declared, "parsed no ValidationErrorType constants from errors.go") - - return declared -} diff --git a/pkg/go/validation/errors.go b/pkg/go/validation/errors.go deleted file mode 100644 index 075973da..00000000 --- a/pkg/go/validation/errors.go +++ /dev/null @@ -1,311 +0,0 @@ -package validation - -import ( - "fmt" - "slices" - "strings" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// 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" -) - -// 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"` -} - -// 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"` - - // Severity states whether this finding makes the model invalid. Findings that - // do not block are reported without failing validation. - Severity fgaerrors.Severity `json:"severity,omitempty"` - - // Category is the part of the model this finding is about. - Category fgaerrors.ModelErrorKind `json:"category,omitempty"` - - Line *Range `json:"line,omitempty"` - Column *Range `json:"column,omitempty"` - File string `json:"file,omitempty"` - Metadata *ErrorMetadata `json:"metadata,omitempty"` - - // Cause is the scoped error this finding wraps, and what Unwrap returns: - // errors.Is identifies the condition, errors.As or Kind the part of the model. - // It is nil for a finding whose code has no sentinel, and for one built directly - // rather than through a constructor. - // - // It stays off the wire because an error field has no concrete type to decode - // into, which would leave ValidationError unable to round-trip. The message, - // severity and metadata carry the same information in JSON. - Cause fgaerrors.ModelError `json:"-"` -} - -// 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) -} - -// Unwrap returns Cause, which is nil for an error built directly rather than -// through a constructor. -func (e *ValidationError) Unwrap() error { - return e.Cause -} - -// Blocks reports whether this finding makes the model invalid. A directly-constructed -// error has no severity set and blocks; see Severity.Blocks. A nil finding is not a -// finding, so it blocks nothing. -func (e *ValidationError) Blocks() bool { - if e == nil { - return false - } - - return e.Severity.Blocks() -} - -// 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 holds every finding in the order it was raised, blocking or not. - // len(Errors) is CountAll, not Count; HasErrors and GetErrors are the - // blocking-only views. - Errors []*ValidationError `json:"errors"` -} - -// findings is the slice every read method below goes through, so a nil collection is -// an empty one in one place rather than in nine. A nil *ValidationErrors reaches these -// methods through a zero ValidationReport, among other paths. -// -// A nil *ValidationError is dropped, because it is not a finding: counting one would -// have CountAll and HasFindings disagree with Blocks and Unwrap, and would put an -// entry in the slice AllFindings hands out that dereferences nil on Severity or -// String. No constructor returns one; a collection built through -// NewValidationErrors, Add or the exported Errors field can hold one. -// -// The scan returns the slice untouched when there is nothing to drop, so the usual -// case does not allocate. -func (e *ValidationErrors) findings() []*ValidationError { - if e == nil { - return nil - } - - if !slices.Contains(e.Errors, nil) { - return e.Errors - } - - held := make([]*ValidationError, 0, len(e.Errors)) - - for _, err := range e.Errors { - if err != nil { - held = append(held, err) - } - } - - return held -} - -// Error implements the error interface for ValidationErrors. -// -// It reports the blocking findings only, so the count in the message agrees with -// Count. -func (e *ValidationErrors) Error() string { - blocking := e.GetErrors() - if len(blocking) == 0 { - return "no validation errors" - } - - plural := "" - if len(blocking) > 1 { - plural = "s" - } - - var errorStrings []string - for _, err := range blocking { - errorStrings = append(errorStrings, err.String()) - } - - return fmt.Sprintf("%d error%s occurred:\n\t* %s\n\n", - len(blocking), plural, strings.Join(errorStrings, "\n\t* ")) -} - -// Unwrap returns every finding, so errors.Is and errors.As reach each sentinel and -// scope through the collection. Non-blocking findings are included, since errors.Is -// asks whether a condition was reported, not whether it blocks. -// -// Because errors.As stops at the first match, enumerating every finding of one -// scope means walking AllFindings. -func (e *ValidationErrors) Unwrap() []error { - held := e.findings() - if len(held) == 0 { - return nil - } - - // findings drops nil entries, so none reaches errors.Is here: handing it a nil - // *ValidationError as a non-nil error panics. - unwrapped := make([]error, 0, len(held)) - for _, err := range held { - unwrapped = append(unwrapped, err) - } - - return unwrapped -} - -// ErrorOrNil returns e as an error, or nil when no finding blocks. -// -// The validation entry points return this, so err != nil means the model is invalid -// rather than that something was reported: a model whose only findings are warnings -// or advisories yields nil. Non-blocking findings alongside a blocking one stay -// reachable through errors.As and AllFindings. -// -// Findings from a model that stays valid are only reachable off this path: -// CreateValidationReport returns the collection itself, and AllFindings on it lists -// everything raised. -func (e *ValidationErrors) ErrorOrNil() error { - if !e.HasErrors() { - return nil - } - - return e -} - -// Add adds a validation error to the collection. -func (e *ValidationErrors) Add(err *ValidationError) { - e.Errors = append(e.Errors, err) -} - -// GetErrors returns the findings that make the model invalid. -// -// Non-blocking findings are excluded; AllFindings returns everything. -func (e *ValidationErrors) GetErrors() []*ValidationError { - held := e.findings() - - blocking := make([]*ValidationError, 0, len(held)) - for _, err := range held { - if err.Blocks() { - blocking = append(blocking, err) - } - } - return blocking -} - -// AllFindings returns every finding, blocking or not, in the order raised. Each -// finding's Severity says how to present it. -func (e *ValidationErrors) AllFindings() []*ValidationError { - return e.findings() -} - -// 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 reports whether any finding makes the model invalid. A model with only -// warnings or advisories is valid, so this is false; HasFindings covers everything -// raised. -func (e *ValidationErrors) HasErrors() bool { - for _, err := range e.findings() { - if err.Blocks() { - return true - } - } - return false -} - -// HasFindings reports whether anything at all was reported, blocking or not. -func (e *ValidationErrors) HasFindings() bool { - return len(e.findings()) > 0 -} - -// Count returns the number of findings that make the model invalid. -func (e *ValidationErrors) Count() int { - count := 0 - for _, err := range e.findings() { - if err.Blocks() { - count++ - } - } - return count -} - -// CountAll returns the total number of findings, blocking or not. -func (e *ValidationErrors) CountAll() int { - return len(e.findings()) -} - -// 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 a632d1b3..00000000 --- a/pkg/go/validation/errors_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package validation - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -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: &Range{Start: 5, End: 5}, - Column: &Range{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: &Range{Start: 3, End: 3}, - Column: &Range{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: &Range{Start: 1, End: 1}, - Column: &Range{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 := &Range{Start: 5, End: 10} - assert.Equal(t, 5, line.Start) - assert.Equal(t, 10, line.End) -} - -func TestColumnRange(t *testing.T) { - column := &Range{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) -} - -// TestCategorySerialisesForEveryCategory checks every finding carries its category -// on the wire under its name. Category counts from iota + 1, so no real category is -// the zero value omitempty drops. -func TestCategorySerialisesForEveryCategory(t *testing.T) { - t.Parallel() - - collector := NewValidationErrors(nil) - collector.Add(newInvalidTypeError(nil, "user", nil, nil)) // object-type - collector.Add(newDuplicateTypeRestrictionError(nil, "user", "viewer", "document", nil, nil)) // relation - collector.Add(newUnusedConditionError(nil, "unused_cond", nil, nil)) // condition - - wantCategories := []string{`"category":"object-type"`, `"category":"relation"`, `"category":"condition"`} - - findings := collector.AllFindings() - require.Len(t, findings, len(wantCategories)) - - for i, want := range wantCategories { - encoded, err := json.Marshal(findings[i]) - require.NoError(t, err) - assert.Containsf(t, string(encoded), want, - "finding %d (%s) must carry its category on the wire", i, findings[i].Metadata.ErrorType) - } -} - -// TestSeveritySerialisesUnderItsName checks the same for severity, and that a -// finding which never set one carries no severity field at all. -func TestSeveritySerialisesUnderItsName(t *testing.T) { - t.Parallel() - - collector := NewValidationErrors(nil) - collector.Add(newInvalidTypeError(nil, "user", nil, nil)) - - findings := collector.AllFindings() - require.Len(t, findings, 1) - - encoded, err := json.Marshal(findings[0]) - require.NoError(t, err) - assert.Contains(t, string(encoded), `"severity":"error"`, - "a classified finding must carry its severity on the wire under its name") - - encoded, err = json.Marshal(&ValidationError{Message: "built without the collector"}) - require.NoError(t, err) - assert.NotContains(t, string(encoded), `"severity"`, - "an unclassified finding must omit severity rather than report one it never set") -} - -// TestValidationErrorWireShape checks the serialised document of a finding with -// every field set. -// -// The key names are the cross-language contract: pkg/js and pkg/java agree with Go -// on msg, line, column and metadata.symbol/errorType, and -// tests/data/dsl-semantic-validation-cases.yaml is written in them. Nothing else in -// this package marshals a finding, so a renamed json tag would otherwise change -// every consumer's output without failing a test. JSONEq compares the whole -// document, so an added key fails here too. -func TestValidationErrorWireShape(t *testing.T) { - t.Parallel() - - encoded, err := json.Marshal(&ValidationError{ - Message: "the relation 'allowed' does not exist.", - Severity: fgaerrors.SeverityError, - Category: fgaerrors.ErrorKindRelation, - Line: &Range{Start: 6, End: 6}, - Column: &Range{Start: 41, End: 48}, - File: "model.fga", - Metadata: &ErrorMetadata{ - Symbol: "allowed", - ErrorType: MissingDefinition, - Module: "core", - Type: "document", - Relation: "reader", - Condition: "inRegion", - OffendingType: "folder", - }, - }) - require.NoError(t, err) - - assert.JSONEq(t, `{ - "msg": "the relation 'allowed' does not exist.", - "severity": "error", - "category": "relation", - "line": {"start": 6, "end": 6}, - "column": {"start": 41, "end": 48}, - "file": "model.fga", - "metadata": { - "symbol": "allowed", - "errorType": "missing-definition", - "module": "core", - "type": "document", - "relation": "reader", - "condition": "inRegion", - "offendingType": "folder" - } - }`, string(encoded)) -} - -// TestValidationErrorWireShapeOmitsUnsetFields checks a finding with nothing but a -// message and the two mandatory metadata fields emits no keys for scope it does not -// have. A consumer tells "no condition" from "condition is empty" by the key's -// absence. -func TestValidationErrorWireShapeOmitsUnsetFields(t *testing.T) { - t.Parallel() - - encoded, err := json.Marshal(&ValidationError{ - Message: "schema version required", - Metadata: &ErrorMetadata{Symbol: "schema", ErrorType: SchemaVersionRequired}, - }) - require.NoError(t, err) - - assert.JSONEq(t, `{ - "msg": "schema version required", - "metadata": {"symbol": "schema", "errorType": "schema-version-required"} - }`, string(encoded)) -} - -// TestValidationErrorsWireShape checks the envelope, which is what a consumer -// decodes first. -func TestValidationErrorsWireShape(t *testing.T) { - t.Parallel() - - encoded, err := json.Marshal(NewValidationErrors(nil)) - require.NoError(t, err) - assert.JSONEq(t, `{"errors": []}`, string(encoded), - "no findings must serialise as an empty list, not null") - - encoded, err = json.Marshal(NewValidationErrors([]*ValidationError{{ - Message: "x", - Metadata: &ErrorMetadata{Symbol: "s", ErrorType: InvalidType}, - }})) - require.NoError(t, err) - assert.JSONEq(t, - `{"errors": [{"msg": "x", "metadata": {"symbol": "s", "errorType": "invalid-type"}}]}`, - string(encoded)) -} diff --git a/pkg/go/validation/findings.go b/pkg/go/validation/findings.go new file mode 100644 index 00000000..b0394256 --- /dev/null +++ b/pkg/go/validation/findings.go @@ -0,0 +1,161 @@ +// 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 ( + "fmt" + "strings" +) + +// 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, so a single finding recovered with +// errors.As prints like 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 +} + +// Findings is every diagnostic raised for one model, in the order raised. It +// follows go/scanner.ErrorList: the slice is the collection and, when +// non-empty, the error. +// +//nolint:errname // named for what it holds, as go/scanner.ErrorList is +type Findings []*Finding + +// add appends f when it is a finding; a nil *Finding means nothing was found. +func (fs Findings) add(f *Finding) Findings { + if f == nil { + return fs + } + + return append(fs, f) +} + +// Error implements the error interface. +func (fs Findings) Error() string { + if len(fs) == 0 { + return "no validation errors" + } + + plural := "" + if len(fs) > 1 { + plural = "s" + } + + messages := make([]string, 0, len(fs)) + for _, f := range fs { + messages = append(messages, f.Error()) + } + + return fmt.Sprintf("%d error%s occurred:\n\t* %s\n\n", len(fs), plural, strings.Join(messages, "\n\t* ")) +} + +// Unwrap returns each finding, so errors.As reaches one through the collection. +func (fs Findings) Unwrap() []error { + errs := make([]error, 0, len(fs)) + for _, f := range fs { + errs = append(errs, f) + } + + return errs +} + +// Err returns the collection as an error, or nil when nothing was found. It is +// the one place a Findings becomes an error, so a caller never receives a +// non-nil error holding an empty collection. +func (fs Findings) Err() error { + if len(fs) == 0 { + return nil + } + + return fs +} diff --git a/pkg/go/validation/findings_test.go b/pkg/go/validation/findings_test.go new file mode 100644 index 00000000..9b066a57 --- /dev/null +++ b/pkg/go/validation/findings_test.go @@ -0,0 +1,142 @@ +package validation + +import ( + "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 TestFindingsError(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "no validation errors", Findings{}.Error()) + }) + + t.Run("one finding", func(t *testing.T) { + t.Parallel() + + findings := Findings{{Message: "first"}} + + assert.Equal(t, "1 error occurred:\n\t* validation error: first\n\n", findings.Error()) + }) + + t.Run("two findings pluralize", func(t *testing.T) { + t.Parallel() + + findings := Findings{{Message: "first"}, {Message: "second"}} + + assert.Equal(t, "2 errors occurred:\n\t* validation error: first\n\t* validation error: second\n\n", + findings.Error()) + }) +} + +func TestFindingsErr(t *testing.T) { + t.Parallel() + + t.Run("nil for no findings", func(t *testing.T) { + t.Parallel() + + require.NoError(t, Findings(nil).Err()) + require.NoError(t, Findings{}.Err()) + }) + + t.Run("the collection itself otherwise", func(t *testing.T) { + t.Parallel() + + findings := Findings{{Message: "boom"}} + err := findings.Err() + + require.Error(t, err) + + var recovered Findings + require.ErrorAs(t, err, &recovered) + assert.Len(t, recovered, 1) + }) +} + +func TestFindingsUnwrap(t *testing.T) { + t.Parallel() + + first := &Finding{Message: "first", Metadata: Metadata{Kind: InvalidName}} + second := &Finding{Message: "second", Metadata: Metadata{Kind: DuplicatedError}} + err := Findings{first, second}.Err() + + // errors.As walks Unwrap() []error and stops at the first finding. + var finding *Finding + require.ErrorAs(t, err, &finding) + assert.Same(t, first, finding) + + require.ErrorIs(t, err, error(first)) + require.ErrorIs(t, err, error(second)) +} + +func TestFindingsAdd(t *testing.T) { + t.Parallel() + + var findings Findings + + findings = findings.add(nil) + assert.Empty(t, findings, "a nil finding is nothing found") + + findings = findings.add(&Finding{Message: "found"}) + assert.Len(t, findings, 1) +} + +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")) + }) +} + +// TestFindingsAsError pins the boundary contract: a validation error is always +// a Findings, and errors.As is the documented way back to the findings. +func TestFindingsAsError(t *testing.T) { + t.Parallel() + + err := Findings{{Message: "boom", Metadata: Metadata{Kind: InvalidName, Symbol: "x"}}}.Err() + + var findings Findings + require.ErrorAs(t, err, &findings) + assert.Equal(t, InvalidName, findings[0].Metadata.Kind) +} 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 index 13f3065c..83a60342 100644 --- a/pkg/go/validation/json_corpus_test.go +++ b/pkg/go/validation/json_corpus_test.go @@ -22,8 +22,8 @@ type jsonCorpusCase struct { } // TestJSONValidationCorpus runs the JSON corpus against ValidateJSON. The JS and Java -// validators both consume this file and nothing in Go read it, so a rule that only a -// JSON model can reach was pinned in the other two SDKs and free to drift here. +// 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() @@ -51,8 +51,7 @@ func TestJSONValidationCorpus(t *testing.T) { protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal([]byte(testCase.JSON), model), "the case's JSON must parse as an authorization model") - result := compareWithCorpus(testCase.ExpectedErrors, - findingsFrom(ValidateJSON(model, DefaultEngineOptions()))) + result := compareWithCorpus(testCase.ExpectedErrors, findingsOf(ValidateJSON(model))) for _, problem := range result.Problems { t.Error(problem) 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 ad170397..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 := NewValidationErrors(nil) - - // Test type name validation - should pass for valid names - isValid := ValidateTypeName("document", collector, nil, nil, nil) - assert.True(t, isValid) - assert.Empty(t, collector.AllFindings()) - - // Test type name validation - should fail for reserved keywords - collector = NewValidationErrors(nil) - isValid = ValidateTypeName("this", collector, nil, nil, nil) - assert.False(t, isValid) - assert.NotEmpty(t, collector.AllFindings()) - - // Test relation name validation - should pass for valid names - collector = NewValidationErrors(nil) - isValid = ValidateRelationName("viewer", "document", collector, nil, nil, nil) - assert.True(t, isValid) - assert.Empty(t, collector.AllFindings()) - - // Test relation name validation - should fail for reserved keywords - collector = NewValidationErrors(nil) - isValid = ValidateRelationName("self", "document", collector, nil, nil, nil) - assert.False(t, isValid) - assert.NotEmpty(t, collector.AllFindings()) -} 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 0ef8551d..46988f7c 100644 --- a/pkg/go/validation/multi_file_validation.go +++ b/pkg/go/validation/multi_file_validation.go @@ -8,35 +8,35 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// MultiFileValidator handles validation across multiple files and modules. -type MultiFileValidator struct { - model *openfgav1.AuthorizationModel - fileToModules *orderedGroups - moduleToFiles *orderedGroups - 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) Findings { + var fs Findings + + files := modulesByFile(model) + for _, file := range files.keys { + if modules := files.values[file]; len(modules) > 1 { + fs = append(fs, multipleModulesInSingleFile(file, modules)) + } + } + + return fs } -// 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. +// orderedGroups records a one-to-many mapping, keeping both the keys and each +// key's values in the order they were first added. // -// Held by pointer: copying the struct copies keys but shares values, so an add -// through the copy leaves the original holding a value under a key it never -// recorded, and iterating keys then drops it. +// 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 newOrderedGroups() *orderedGroups { - return &orderedGroups{values: make(map[string][]string)} -} - func (g *orderedGroups) add(key, value string) { existing, seen := g.values[key] if !seen { @@ -50,161 +50,36 @@ func (g *orderedGroups) add(key, value string) { g.values[key] = append(existing, value) } -// get returns a non-nil copy, so a caller cannot reorder the record it reads. Not -// slices.Clone, which returns nil for an absent key and so would have the accessors -// hand back a slice that marshals as null rather than []. -func (g *orderedGroups) get(key string) []string { - values := make([]string, 0, len(g.values[key])) - - return append(values, g.values[key]...) -} - -// ModuleInfo represents information about a module. -type ModuleInfo struct { - Name string - Files []string - Types []string -} - -// FileInfo represents information about a file. -type FileInfo struct { - Path string - Modules []string -} - -func NewMultiFileValidator(model *openfgav1.AuthorizationModel) *MultiFileValidator { - validator := &MultiFileValidator{ - model: model, - fileToModules: newOrderedGroups(), - moduleToFiles: newOrderedGroups(), - typeModuleMap: make(map[string]string), - conditionModuleMap: make(map[string]string), - } - validator.buildFileMappings() - - return validator -} - -// buildFileMappings 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 reported after the module of every type, not after its own -// type's. -func (mfv *MultiFileValidator) buildFileMappings() { - if mfv.model == nil { - return - } - - for _, typeDef := range mfv.model.GetTypeDefinitions() { - file := typeDef.GetMetadata().GetSourceInfo().GetFile() - module := typeDef.GetMetadata().GetModule() +// 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)} + record := func(file, module string) { if file != "" && module != "" { - mfv.addFileModuleMapping(file, module) - mfv.typeModuleMap[typeDef.GetType()] = module + files.add(filepath.Clean(file), module) } } - for _, typeDef := range mfv.model.GetTypeDefinitions() { - // Relation names arrive in a proto map, which has no order of its own, so - // they are walked in name order. - for _, relation := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { - relationMetadata := typeDef.GetMetadata().GetRelations()[relation] - - // A relation may name its own file and module, and falls back to its - // type's for whichever of the two it leaves unset. - file := relationMetadata.GetSourceInfo().GetFile() - if file == "" { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - } - - module := relationMetadata.GetModule() - if module == "" { - module = typeDef.GetMetadata().GetModule() - } - - if file != "" && module != "" { - mfv.addFileModuleMapping(file, module) - } - } + for _, typeDef := range model.GetTypeDefinitions() { + record(typeMeta(typeDef)) } - for _, conditionName := range slices.Sorted(maps.Keys(mfv.model.GetConditions())) { - condition := mfv.model.GetConditions()[conditionName] - file := condition.GetMetadata().GetSourceInfo().GetFile() - module := condition.GetMetadata().GetModule() - - if file != "" && module != "" { - mfv.addFileModuleMapping(file, module) - mfv.conditionModuleMap[conditionName] = 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)) } } -} - -func (mfv *MultiFileValidator) addFileModuleMapping(file, module string) { - file = filepath.Clean(file) - mfv.fileToModules.add(file, module) - mfv.moduleToFiles.add(module, file) -} - -// ValidateMultiFileConsistency validates consistency across multiple files. -func ValidateMultiFileConsistency(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - // The rule itself lives in ValidateMultipleModulesInFile, which takes the files - // this validator collected; the two must not drift. - ValidateMultipleModulesInFile(errs, NewMultiFileValidator(model).GetFileInfo()) -} - -func (mfv *MultiFileValidator) GetModuleInfo() []ModuleInfo { - modules := make([]ModuleInfo, 0, len(mfv.moduleToFiles.keys)) - - for _, moduleName := range mfv.moduleToFiles.keys { - info := ModuleInfo{Name: moduleName, Files: mfv.moduleToFiles.get(moduleName), Types: make([]string, 0)} - - // Declaration order: ranging typeModuleMap would list the types in whatever - // order the runtime handed back. A name the model declares twice is reached - // once per declaration, and typeModuleMap resolves it to a single module, so - // each name is listed once rather than once per declaration. - for _, typeDef := range mfv.model.GetTypeDefinitions() { - typeName := typeDef.GetType() - - if mfv.typeModuleMap[typeName] != moduleName || slices.Contains(info.Types, typeName) { - continue - } - - info.Types = append(info.Types, typeName) - } - modules = append(modules, info) - } - - return modules -} - -func (mfv *MultiFileValidator) GetFileInfo() []FileInfo { - files := make([]FileInfo, 0, len(mfv.fileToModules.keys)) - for _, filePath := range mfv.fileToModules.keys { - files = append(files, FileInfo{Path: filePath, Modules: mfv.fileToModules.get(filePath)}) + 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) IsMultiModuleProject() bool { return len(mfv.moduleToFiles.keys) > 1 } -func (mfv *MultiFileValidator) IsMultiFileProject() bool { return len(mfv.fileToModules.keys) > 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 { - return mfv.moduleToFiles.get(moduleName) -} - -func (mfv *MultiFileValidator) GetModulesForFile(filePath string) []string { - return mfv.fileToModules.get(filePath) -} diff --git a/pkg/go/validation/multi_file_validation_test.go b/pkg/go/validation/multi_file_validation_test.go deleted file mode 100644 index 286248e1..00000000 --- a/pkg/go/validation/multi_file_validation_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package validation - -import ( - "slices" - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// multiModuleModel names one file, core.fga, from all three places a model can name a -// module: a type, a relation, and a condition. It is the shape the shared corpus uses -// for this rule, with a second relation and a second condition so map iteration has -// something to reorder. -func multiModuleModel() *openfgav1.AuthorizationModel { - return &openfgav1.AuthorizationModel{ - SchemaVersion: "1.2", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "user", - Relations: map[string]*openfgav1.Userset{ - "granted": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - }, - Metadata: &openfgav1.Metadata{ - Module: "core", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - Relations: map[string]*openfgav1.RelationMetadata{ - "granted": { - Module: "usermodule", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - }, - }, - }, - }, - { - Type: "org", - Relations: map[string]*openfgav1.Userset{ - "member": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - "owner": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - }, - Metadata: &openfgav1.Metadata{ - Module: "other", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - Relations: map[string]*openfgav1.RelationMetadata{ - "member": { - Module: "relationmodule", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - }, - "owner": { - Module: "ownermodule", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - }, - }, - }, - }, - }, - Conditions: map[string]*openfgav1.Condition{ - "zeta": { - Name: "zeta", - Metadata: &openfgav1.ConditionMetadata{ - Module: "zetamodule", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - }, - }, - "alpha": { - Name: "alpha", - Metadata: &openfgav1.ConditionMetadata{ - Module: "alphamodule", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - }, - }, - }, - } -} - -// TestMultiFileCollectionFollowsTheModelOrder pins the order the modules of one file -// are collected in: every type, then every relation, then every condition. That is the -// order the reference collects them in, and a file's modules are joined into the -// message it reports, so the shared corpus fails on any other order. -func TestMultiFileCollectionFollowsTheModelOrder(t *testing.T) { - t.Parallel() - - model := multiModuleModel() - - // The passes are separate, so the first type's relation is collected after the - // second type, not after its own type. Deliberately not in name order either: - // sorting the modules would be deterministic and would still report the model - // differently from the other SDKs. - want := []string{ - "core", "other", - "usermodule", "relationmodule", "ownermodule", - "alphamodule", "zetamodule", - } - - // Relations and conditions arrive in proto maps, which have no order of their own, - // so one passing run proves nothing: seven modules admit 5040 orders, which 100 - // runs would not agree on by chance. - for i := 0; i < 100; i++ { - files := NewMultiFileValidator(model).GetFileInfo() - - require.Len(t, files, 1, "every definition in this model names core.fga") - require.Equal(t, "core.fga", files[0].Path) - require.Equalf(t, want, files[0].Modules, "run %d collected the modules in a different order", i) - } -} - -// TestValidateMultiFileConsistencyReportsEveryModuleInTheFile is the same rule from the -// entry point the engine calls, including the modules a relation and a condition name, -// which are the two the type-level walk alone would miss. -func TestValidateMultiFileConsistencyReportsEveryModuleInTheFile(t *testing.T) { - t.Parallel() - - collector := NewValidationErrors(nil) - ValidateMultiFileConsistency(collector, multiModuleModel(), nil) - - findings := collector.AllFindings() - require.Len(t, findings, 1) - assert.Equal(t, - "file core.fga would contain multiple module definitions "+ - "(core, other, usermodule, relationmodule, ownermodule, alphamodule, zetamodule) "+ - "when transforming to DSL. Only one module can be defined per file.", - findings[0].Message) - assert.Equal(t, MultipleModulesInFile, findings[0].Metadata.ErrorType) - assert.Equal(t, "core.fga", findings[0].Metadata.Symbol) -} - -// TestRelationInheritsItsTypesFileAndModule covers the fallback field by field: a -// relation that names only a file belongs to its type's module, and a relation that -// names neither belongs to both of its type's. -func TestRelationInheritsItsTypesFileAndModule(t *testing.T) { - t.Parallel() - - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.2", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "org", - Relations: map[string]*openfgav1.Userset{ - "member": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - "owner": {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - }, - Metadata: &openfgav1.Metadata{ - Module: "core", - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - Relations: map[string]*openfgav1.RelationMetadata{ - "member": {SourceInfo: &openfgav1.SourceInfo{File: "extra.fga"}}, - "owner": {}, - }, - }, - }, - }, - } - - validator := NewMultiFileValidator(model) - - assert.Equal(t, []FileInfo{ - {Path: "core.fga", Modules: []string{"core"}}, - {Path: "extra.fga", Modules: []string{"core"}}, - }, validator.GetFileInfo()) - - assert.True(t, validator.IsMultiFileProject()) - assert.False(t, validator.IsMultiModuleProject(), "both files hold the same module") - assert.Equal(t, []string{"core.fga", "extra.fga"}, validator.GetFilesForModule("core")) - - collector := NewValidationErrors(nil) - ValidateMultiFileConsistency(collector, model, nil) - assert.Empty(t, collector.AllFindings(), "neither file holds more than one module") -} - -func TestMultiFileValidatorReads(t *testing.T) { - t.Parallel() - - validator := NewMultiFileValidator(multiModuleModel()) - - assert.Equal(t, "core", validator.GetModuleForType("user")) - assert.Equal(t, "other", validator.GetModuleForType("org")) - assert.Empty(t, validator.GetModuleForType("absent")) - - assert.Equal(t, "alphamodule", validator.GetModuleForCondition("alpha")) - assert.Equal(t, "zetamodule", validator.GetModuleForCondition("zeta")) - assert.Empty(t, validator.GetModuleForCondition("absent")) - - assert.Equal(t, []string{ - "core", "other", - "usermodule", "relationmodule", "ownermodule", - "alphamodule", "zetamodule", - }, validator.GetModulesForFile("core.fga")) - // Empty rather than nil, so an accessor's result marshals as [] whichever key it - // was asked for. assert.Equal tells the two apart where assert.Empty does not. - assert.Equal(t, []string{}, validator.GetModulesForFile("absent.fga")) - assert.Equal(t, []string{}, validator.GetFilesForModule("absent")) - - // A module reaches GetModuleInfo whether a type, a relation or a condition named - // it, and only a type's module carries types. - assert.Equal(t, []ModuleInfo{ - {Name: "core", Files: []string{"core.fga"}, Types: []string{"user"}}, - {Name: "other", Files: []string{"core.fga"}, Types: []string{"org"}}, - {Name: "usermodule", Files: []string{"core.fga"}, Types: []string{}}, - {Name: "relationmodule", Files: []string{"core.fga"}, Types: []string{}}, - {Name: "ownermodule", Files: []string{"core.fga"}, Types: []string{}}, - {Name: "alphamodule", Files: []string{"core.fga"}, Types: []string{}}, - {Name: "zetamodule", Files: []string{"core.fga"}, Types: []string{}}, - }, validator.GetModuleInfo()) - - assert.True(t, validator.IsMultiModuleProject()) - assert.False(t, validator.IsMultiFileProject(), "one file, however many modules it holds") - - // The reads return copies: reordering what a caller was handed must not reorder - // the record it came from. - modules := validator.GetModulesForFile("core.fga") - modules[0] = "clobbered" - assert.Equal(t, "core", validator.GetModulesForFile("core.fga")[0]) -} - -// TestModuleInfoListsADuplicatedTypeOnce covers reading a model that declares one type -// name twice, which is itself a duplicated-error but is exactly when a caller reaches -// for GetModuleInfo. Walking the declarations reaches such a name once per declaration, -// and typeModuleMap holds one module for it, so without the guard the module that name -// resolves to lists it as many times as the model declares it. -func TestModuleInfoListsADuplicatedTypeOnce(t *testing.T) { - t.Parallel() - - declaredIn := func(module string) *openfgav1.Metadata { - return &openfgav1.Metadata{ - Module: module, - SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, - } - } - - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.2", - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document", Metadata: declaredIn("first")}, - {Type: "folder", Metadata: declaredIn("first")}, - {Type: "document", Metadata: declaredIn("second")}, - }, - } - - modules := NewMultiFileValidator(model).GetModuleInfo() - - // document resolves to the module that declared it last, and appears there once. - // The module it no longer resolves to does not list it at all. - assert.Equal(t, []ModuleInfo{ - {Name: "first", Files: []string{"core.fga"}, Types: []string{"folder"}}, - {Name: "second", Files: []string{"core.fga"}, Types: []string{"document"}}, - }, modules) - - for _, module := range modules { - assert.Len(t, slices.Compact(slices.Clone(module.Types)), len(module.Types), - "module %q lists a type name more than once", module.Name) - } -} - -// TestMultiFileValidatorWithoutModules covers a model that names no file or module at -// all, which is every model written as a single DSL file. -func TestMultiFileValidatorWithoutModules(t *testing.T) { - t.Parallel() - - 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{}}}}, - }, - }, - } - - validator := NewMultiFileValidator(model) - - assert.Empty(t, validator.GetFileInfo()) - assert.Empty(t, validator.GetModuleInfo()) - assert.False(t, validator.IsMultiFileProject()) - assert.False(t, validator.IsMultiModuleProject()) - - collector := NewValidationErrors(nil) - ValidateMultiFileConsistency(collector, model, nil) - assert.Empty(t, collector.AllFindings()) - - // A nil model reaches the same entry point through the engine, and reports nothing - // rather than panicking. - nilCollector := NewValidationErrors(nil) - ValidateMultiFileConsistency(nilCollector, nil, nil) - assert.Empty(t, nilCollector.AllFindings()) - assert.Empty(t, NewMultiFileValidator(nil).GetFileInfo()) -} diff --git a/pkg/go/validation/name_validation.go b/pkg/go/validation/name_validation.go index cd273a16..31ee8cca 100644 --- a/pkg/go/validation/name_validation.go +++ b/pkg/go/validation/name_validation.go @@ -5,245 +5,98 @@ import ( "maps" "regexp" "slices" - "strings" 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. The compiledNameRules map is keyed by the 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, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { - // First check if it's a reserved keyword - if IsReservedTypeName(typeName) { - errs.Add(newReservedTypeNameError(lines, typeName, meta, lineIndex)) - 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) { - errs.Add(newInvalidNameError(lines, typeName, typeNameRule, nil, meta, lineIndex)) - 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, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { - // First check if it's a reserved keyword - if IsReservedRelationName(relationName) { - errs.Add(newReservedRelationNameError(lines, relationName, typeName, meta, lineIndex)) - 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) { - errs.Add(newInvalidNameError(lines, relationName, relationNameRule, &typeName, meta, lineIndex)) - return false - } - - return true -} - -// ValidateConditionName validates a condition name with regex pattern. -func ValidateConditionName(conditionName string, errs *ValidationErrors, lines []string, lineIndex *int, meta *Meta) bool { - if !validateFieldValue(conditionNameRule, conditionName) { - errs.Add(newInvalidConditionNameError(lines, conditionName, conditionNameRule, meta, lineIndex)) - 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. -// The skipIndex argument, 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(errs *ValidationErrors, typeName string, relationNames []string, - typeLineIndex *int, meta *Meta, lines []string) { - // Validate type name - ValidateTypeName(typeName, errs, lines, typeLineIndex, meta) - - // Validate relation names - for _, relationName := range relationNames { - relationLineIndex := GetRelationLineNumber(relationName, lines, nil) - ValidateRelationName(relationName, typeName, errs, lines, 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(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } +func validateNames(model *openfgav1.AuthorizationModel, src source) Findings { + var fs Findings 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, errs, lines, typeLineIndex, meta) + file := typeDef.GetMetadata().GetSourceInfo().GetFile() + module := typeDef.GetMetadata().GetModule() + + typeLine := src.typeLine(typeName) + fs = fs.add(validateTypeName(typeName).at(src, typeLine).in(file, module)) + // 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())) { - relationLineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - ValidateRelationName(relationName, typeName, errs, lines, relationLineIndex, meta) + relationLine := src.relationLine(relationName, typeLine) + fs = fs.add(validateRelationName(relationName, typeName).at(src, relationLine).in(file, module)) } } conditions := model.GetConditions() for _, conditionName := range slices.Sorted(maps.Keys(conditions)) { condition := conditions[conditionName] - conditionLineIndex := GetConditionLineNumber(conditionName, lines, nil) - meta := &Meta{ - File: condition.GetMetadata().GetSourceInfo().GetFile(), - Module: condition.GetMetadata().GetModule(), - } - ValidateConditionName(conditionName, errs, lines, conditionLineIndex, meta) + file := condition.GetMetadata().GetSourceInfo().GetFile() + module := condition.GetMetadata().GetModule() + + fs = fs.add(validateConditionName(conditionName).at(src, src.conditionLine(conditionName)).in(file, module)) } + + return fs } diff --git a/pkg/go/validation/name_validation_test.go b/pkg/go/validation/name_validation_test.go index a8f9d6b5..5ea62325 100644 --- a/pkg/go/validation/name_validation_test.go +++ b/pkg/go/validation/name_validation_test.go @@ -4,630 +4,119 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -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) -} +func TestValidateTypeName(t *testing.T) { + t.Parallel() -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("valid name yields nothing", func(t *testing.T) { + t.Parallel() + assert.Nil(t, validateTypeName("document")) + }) -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 := NewValidationErrors(nil) - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} - - result := ValidateTypeName(tt.typeName, collector, nil, &lineIndex, meta) - - assert.Equal(t, tt.expectedValid, result) - - errors := collector.AllFindings() - 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) - } - }) - } + t.Run("reserved keywords", func(t *testing.T) { + t.Parallel() + + 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 := NewValidationErrors(nil) - lineIndex := 8 - meta := &Meta{File: "test.fga", Module: "test"} - - result := ValidateRelationName(tt.relationName, tt.typeName, collector, nil, &lineIndex, meta) - - assert.Equal(t, tt.expectedValid, result) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - lineIndex := 10 - meta := &Meta{File: "test.fga", Module: "test"} - - result := ValidateConditionName(tt.conditionName, collector, nil, &lineIndex, meta) - - assert.Equal(t, tt.expectedValid, result) - - errors := collector.AllFindings() - 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) - // The finding is scoped to the condition, not to a type. - assert.Equal(t, tt.conditionName, errors[0].Metadata.Condition) - assert.Empty(t, errors[0].Metadata.Type) - } - }) - } -} + 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 := NewValidationErrors(nil) - 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.AllFindings() - 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 := NewValidationErrors(nil) - typeValid := ValidateTypeName(name, collector, nil, 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 = NewValidationErrors(nil) - relationValid := ValidateRelationName(name, "parent_type", collector, nil, nil, nil) - assert.True(t, relationValid, "Expected %s to be valid relation name", name) - } + findings := 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 := NewValidationErrors(nil) - typeValid := ValidateTypeName(keyword, collector, nil, nil, nil) - assert.False(t, typeValid, "Expected %s to be invalid type name", keyword) + findings := validateNames(modelWithRelations(t, "self", "viewer"), source{}) - collector = NewValidationErrors(nil) - relationValid := ValidateRelationName(keyword, "parent_type", collector, nil, 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 b1a133d8..52d7a84b 100644 --- a/pkg/go/validation/schema_validation.go +++ b/pkg/go/validation/schema_validation.go @@ -1,93 +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) Findings { + 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 Findings{schemaVersionRequired().at(src, 0)} + case "1.1", "1.2": return nil - } - // 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. This mirrors the reference's - // getSchemaLineNumber; without it a commented schema line resolves to no - // position and the finding reaches the caller with no line or column. - pattern := `^\s*schema\s+` + regexp.QuoteMeta(schemaVersion) + `(\s+#.*)?\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(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - schemaVersion := model.GetSchemaVersion() - if schemaVersion == "" { - lineIndex := 0 - errs.Add(newSchemaVersionRequiredError(lines, &lineIndex)) - return - } - switch schemaVersion { - case SchemaVersion11, SchemaVersion12: - // Supported — nothing to report. case "1.0": // Recognized but retired. - errs.Add(newSchemaVersionUnsupportedError(lines, schemaVersion, GetSchemaLineNumber(schemaVersion, lines))) + return Findings{schemaVersionUnsupported(version).at(src, src.schemaLine(version))} default: // Never a valid schema version. - errs.Add(newInvalidSchemaVersionError(lines, schemaVersion, GetSchemaLineNumber(schemaVersion, lines))) + return Findings{invalidSchemaVersion(version).at(src, src.schemaLine(version))} } } - -// ValidateMultipleModulesInFile reports every file that declares more than one -// module. -// -// It reports the files, and each file's modules, in the order they were collected -// from the model, which is the order the reference reports them in and the order the -// shared corpus expects. -func ValidateMultipleModulesInFile(errs *ValidationErrors, files []FileInfo) { - for _, file := range files { - if len(file.Modules) <= 1 { - continue - } - - errs.Add(newMultipleModulesInSingleFileError(file.Path, file.Modules)) - } -} - -// ValidateBasicModelStructure performs basic model structure validation. -func ValidateBasicModelStructure(errs *ValidationErrors, model *openfgav1.AuthorizationModel, - files []FileInfo, lines []string) { - ValidateSchemaVersion(errs, model, lines) - ValidateMultipleModulesInFile(errs, files) -} diff --git a/pkg/go/validation/schema_validation_test.go b/pkg/go/validation/schema_validation_test.go index 08e326a9..0ea7a6bb 100644 --- a/pkg/go/validation/schema_validation_test.go +++ b/pkg/go/validation/schema_validation_test.go @@ -5,391 +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 := NewValidationErrors(nil) - - ValidateSchemaVersion(collector, tt.model, tt.lines) + t.Parallel() - errors := collector.AllFindings() - 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 - files []FileInfo - expectedErrorCount int - expectedFile string - expectedMessage string - }{ - { - name: "no files", - files: nil, - expectedErrorCount: 0, - }, - { - name: "single module per file", - files: []FileInfo{ - {Path: "file1.fga", Modules: []string{"module1"}}, - {Path: "file2.fga", Modules: []string{"module2"}}, - }, - expectedErrorCount: 0, - }, - { - name: "multiple modules in single file", - files: []FileInfo{ - {Path: "file1.fga", Modules: []string{"module1", "module2", "module3"}}, - }, - expectedErrorCount: 1, - expectedFile: "file1.fga", - expectedMessage: "file file1.fga would contain multiple module definitions " + - "(module1, module2, module3) when transforming to DSL. Only one module can be defined per file.", - }, - { - name: "mixed: some files with single, some with multiple modules", - files: []FileInfo{ - {Path: "file1.fga", Modules: []string{"module1"}}, - {Path: "file2.fga", Modules: []string{"module2", "module3"}}, - {Path: "file3.fga", Modules: []string{"module4"}}, - }, - expectedErrorCount: 1, - expectedFile: "file2.fga", - expectedMessage: "file file2.fga would contain multiple module definitions " + - "(module2, module3) when transforming to DSL. Only one module can be defined per file.", - }, - { - // The modules reach the message in the order they were collected, which is - // the order the reference reports them in. Sorting them here would read as - // harmless and would diverge from the other SDKs. - name: "modules are reported in the order given, not in name order", - files: []FileInfo{ - {Path: "core.fga", Modules: []string{"zulu", "alpha", "mike"}}, - }, - expectedErrorCount: 1, - expectedFile: "core.fga", - expectedMessage: "file core.fga would contain multiple module definitions " + - "(zulu, alpha, mike) when transforming to DSL. Only one module can be defined per file.", - }, - } + 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 := NewValidationErrors(nil) + assert.Empty(t, validateSchemaVersion(model("1.1"), source{})) + assert.Empty(t, validateSchemaVersion(model("1.2"), source{})) + }) - ValidateMultipleModulesInFile(collector, tt.files) + t.Run("missing version is required at line zero", func(t *testing.T) { + t.Parallel() - errors := collector.AllFindings() - assert.Len(t, errors, tt.expectedErrorCount) + findings := 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) - assert.Equal(t, tt.expectedMessage, errors[0].Message) - } - }) - } -} + 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) + }) -func TestValidateBasicModelStructure(t *testing.T) { - tests := []struct { - name string - model *openfgav1.AuthorizationModel - files []FileInfo - lines []string - expectedErrorCount int - }{ - { - name: "valid model structure", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - files: []FileInfo{ - {Path: "file1.fga", Modules: []string{"module1"}}, - }, - expectedErrorCount: 0, - }, - { - name: "missing schema version", - model: &openfgav1.AuthorizationModel{}, - files: nil, - expectedErrorCount: 1, - }, - { - name: "invalid schema version", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - }, - files: nil, - expectedErrorCount: 1, - }, - { - name: "multiple modules in file", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - files: []FileInfo{ - {Path: "file1.fga", Modules: []string{"module1", "module2"}}, - }, - expectedErrorCount: 1, - }, - { - name: "multiple errors", - model: &openfgav1.AuthorizationModel{}, - files: []FileInfo{ - {Path: "file1.fga", Modules: []string{"module1", "module2"}}, - }, - expectedErrorCount: 2, - }, - } + t.Run("1.0 is recognized but retired", func(t *testing.T) { + t.Parallel() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewValidationErrors(nil) + findings := validateSchemaVersion(model("1.0"), newSource("model\n schema 1.0\ntype user")) - ValidateBasicModelStructure(collector, tt.model, tt.files, 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) + }) - errors := collector.AllFindings() - assert.Len(t, errors, tt.expectedErrorCount) - }) - } -} + t.Run("anything else was never valid", func(t *testing.T) { + t.Parallel() -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 := 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 := NewValidationErrors(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.AllFindings()) + findings := validateSchemaVersion(model("1.3"), source{}) - // Test invalid schema version - collector = NewValidationErrors(nil) - invalidModel := &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - } - ValidateSchemaVersion(collector, invalidModel, nil) - errors := collector.AllFindings() - 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 96c2bbc0..061f8d33 100644 --- a/pkg/go/validation/semantic_validation.go +++ b/pkg/go/validation/semantic_validation.go @@ -7,281 +7,198 @@ import ( 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 -} - -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 -} - -// 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(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateRelationReferences(errs, NewSemanticValidator(model), lines) -} +// 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) Findings { + var fs Findings -func validateRelationReferences(errs *ValidationErrors, 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) - // Relations are walked in name order here and in every other phase: they - // reach us in a proto map, which has no order, so ranging it directly would - // report the same model's findings in a different order each run. if meta := typeDef.GetMetadata(); meta != nil { relationsMetadata := meta.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { - validateTypeRestrictions(errs, validator, typeName, relationName, - relationsMetadata[relationName], typeLineIndex, lines) + fs = append(fs, validateTypeRestrictions(idx, src, typeDef, relationName, + relationsMetadata[relationName], typeLine)...) } } relations := typeDef.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relations)) { - validateUsersetReferences(errs, validator, typeName, relationName, - relations[relationName], typeLineIndex, lines) + fs = append(fs, validateUsersetReferences(idx, src, typeDef, relationName, + relations[relationName], typeLine)...) } } + + return fs } -func validateTypeRestrictions(errs *ValidationErrors, 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) Findings { 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 Findings + + 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) - errs.Add(newInvalidTypeError(lines, restrictedType, 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 - // offendingType is the enclosing type the restriction was written in. - errs.Add(newInvalidTypeRelationError(lines, invalidTypeRelationArgs{ - symbol: symbol, - typeName: restrictedType, - relationName: relationName, - offendingRelation: rel, - offendingType: typeName, - meta: meta, - lineIndex: lineIndex, - })) - } + + // 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(errs *ValidationErrors, 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) Findings { if userset == nil { - return - } - var file, module string - if typeDef := validator.GetTypeDefinition(typeName); typeDef != nil { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - module = typeDef.GetMetadata().GetModule() + return nil } - meta := &Meta{File: file, Module: module} - if cu := userset.GetComputedUserset(); cu != nil { + var fs Findings + + 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) - errs.Add(newInvalidRelationError(lines, targetRelation, typeName, relationName, meta, lineIndex)) - } + 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(errs, 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(errs, 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(errs, validator, typeName, relationName, child, typeLineIndex, lines) + fs = append(fs, validateUsersetReferences(idx, src, typeDef, relationName, child, typeLine)...) } } + if diff := userset.GetDifference(); diff != nil { - validateUsersetReferences(errs, validator, typeName, relationName, diff.GetBase(), typeLineIndex, lines) - validateUsersetReferences(errs, 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(errs *ValidationErrors, 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) Findings { 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) { - errs.Add(newInvalidTypeRelationError(lines, invalidTypeRelationArgs{ - symbol: symbol, - typeName: typeName, - relationName: relationName, - offendingRelation: fromRelation, - offendingType: typeName, - meta: meta, - lineIndex: lineIndex, - })) - return + // 1. The tupleset relation must exist on the current type. + if !idx.relationDefined(typeName, fromRelation) { + return Findings{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 { - errs.Add(newTupleUsersetRequiresDirectError(lines, 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 Findings{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 Findings + 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. - errs.Add(newTupleUsersetRequiresDirectError(lines, 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 { - errs.Add(newInvalidRelationOnTuplesetError(lines, invalidRelationOnTuplesetArgs{ - symbol: symbol, - typeName: tr.GetType(), - typeDef: typeName, - relationName: relationName, - offendingRelation: targetRelation, - parent: fromRelation, - meta: meta, - lineIndex: lineIndex, - })) + 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 08926783..00000000 --- a/pkg/go/validation/semantic_validation_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package validation - -import ( - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -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.GetType()) - - userType := validator.GetTypeDefinition("user") - assert.NotNil(t, userType) - assert.Equal(t, "user", userType.GetType()) - - 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 := NewValidationErrors(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.AllFindings() - 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 := NewValidationErrors(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.AllFindings() - require.Len(t, errors, 1) - assert.Equal(t, InvalidRelationType, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_relation") - - // The restriction names user#undefined_relation, so the finding is scoped to - // the restricted type, while offendingType is the type the restriction was - // written in. This is the split pkg/js reports: typeName is the restricted - // type, offendingType the enclosing one. - assert.Equal(t, "user", errors[0].Metadata.Type) - assert.Equal(t, "viewer", errors[0].Metadata.Relation) - assert.Equal(t, "document", errors[0].Metadata.OffendingType) - - var scoped *fgaerrors.ErrRelation - require.ErrorAs(t, errors[0], &scoped) - assert.Equal(t, "user", scoped.ObjectType) - assert.Equal(t, "viewer", scoped.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 := NewValidationErrors(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.AllFindings() - require.Len(t, errors, 1) - assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) - assert.Equal(t, "viewer", errors[0].Metadata.Relation) - assert.Contains(t, errors[0].Message, "undefined_relation") - - var scoped *fgaerrors.ErrRelation - require.ErrorAs(t, errors[0], &scoped) - assert.Equal(t, "document", scoped.ObjectType) - assert.Equal(t, "viewer", scoped.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 := NewValidationErrors(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.AllFindings() - 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/severity_fixtures_test.go b/pkg/go/validation/severity_fixtures_test.go deleted file mode 100644 index 4241559c..00000000 --- a/pkg/go/validation/severity_fixtures_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package validation - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// severityFixtureFile holds the Go-only expectations for severity, category and -// criticality. See the header of that file for why it is not in tests/data. -const severityFixtureFile = "testdata/severity-category-cases.yaml" - -type severityFixtureScope struct { - ObjectType string `yaml:"object_type"` - Relation string `yaml:"relation"` - Condition string `yaml:"condition"` -} - -type severityFixtureExpectation struct { - ErrorType string `yaml:"error_type"` - Severity string `yaml:"severity"` - Category string `yaml:"category"` - Critical bool `yaml:"critical"` - Sentinel string `yaml:"sentinel"` - Scope severityFixtureScope `yaml:"scope"` -} - -type severityFixtureCase struct { - Name string `yaml:"name"` - DSL string `yaml:"dsl"` - Expected []severityFixtureExpectation `yaml:"expected"` -} - -// sentinelsByName resolves the sentinel a fixture names. YAML cannot reference a Go -// value, so fixtures name the sentinel as a string; an unknown name fails rather -// than being skipped. -var sentinelsByName = map[string]error{ - "ErrInvalidType": fgaerrors.ErrInvalidType, - "ErrDuplicateDefinition": fgaerrors.ErrDuplicateDefinition, - "ErrNoEntrypoints": fgaerrors.ErrNoEntrypoints, - "ErrConditionUnReferenced": fgaerrors.ErrConditionUnReferenced, - "ErrReservedKeywords": fgaerrors.ErrReservedKeywords, - "ErrObjectTypeUndefined": fgaerrors.ErrObjectTypeUndefined, - "ErrRelationUndefined": fgaerrors.ErrRelationUndefined, - "ErrInvalidRelationType": fgaerrors.ErrInvalidRelationType, - "ErrInvalidSchemaVersion": fgaerrors.ErrInvalidSchemaVersion, - "ErrMultipleModulesInFile": fgaerrors.ErrMultipleModulesInFile, - "ErrInvalidWildcard": fgaerrors.ErrInvalidWildcard, - "ErrConditionUndefined": fgaerrors.ErrConditionUndefined, - "ErrConditionNameMismatch": fgaerrors.ErrConditionNameMismatch, - "ErrInvalidName": fgaerrors.ErrInvalidName, - "ErrDirectlyAssignableRelation": fgaerrors.ErrDirectlyAssignableRelation, -} - -func loadSeverityFixtures(t *testing.T) []severityFixtureCase { - t.Helper() - - contents, err := os.ReadFile(severityFixtureFile) - require.NoError(t, err, "reading %s", severityFixtureFile) - - var cases []severityFixtureCase - require.NoError(t, yaml.Unmarshal(contents, &cases)) - require.NotEmpty(t, cases, "fixture file parsed to no cases") - - return cases -} - -// TestSeverityFixtures runs the Go-only fixtures. Each expectation names a finding -// the validator must produce, with its severity, category, criticality, the sentinel -// errors.Is matches and the scope errors.As exposes. -func TestSeverityFixtures(t *testing.T) { - t.Parallel() - - for _, fixture := range loadSeverityFixtures(t) { - t.Run(fixture.Name, func(t *testing.T) { - t.Parallel() - - validationErrors := validateDSL(t, fixture.DSL) - require.NotNil(t, validationErrors) - - findings := validationErrors.AllFindings() - - for _, want := range fixture.Expected { - sentinel, ok := sentinelsByName[want.Sentinel] - require.Truef(t, ok, - "fixture names sentinel %q, which is not in sentinelsByName", want.Sentinel) - - matched := findSeverityFixtureMatch(findings, want) - require.NotNilf(t, matched, - "no finding matched error_type=%q scope=%+v; got %s", - want.ErrorType, want.Scope, validationErrors.Error()) - - assert.Equal(t, want.Severity, matched.Severity.String()) - assert.Equal(t, want.Category, matched.Category.String()) - assert.Equal(t, want.Critical, isCriticalErrorType(matched.Metadata.ErrorType)) - assert.Equal(t, want.Severity == "error", matched.Blocks()) - - require.ErrorIsf(t, error(matched), sentinel, - "finding %q does not match %s via errors.Is", want.ErrorType, want.Sentinel) - - // The finding was selected on the scope errors.As reports, so - // asserting the metadata here checks the two agree: the metadata - // is derived from the cause and must not drift from it. - require.NotNil(t, matched.Metadata) - assert.Equal(t, want.Scope.ObjectType, matched.Metadata.Type) - assert.Equal(t, want.Scope.Relation, matched.Metadata.Relation) - assert.Equal(t, want.Scope.Condition, matched.Metadata.Condition) - } - }) - } -} - -// findSeverityFixtureMatch locates the finding an expectation refers to. Matching -// on error type alone is not enough: the no-entrypoint case produces one finding -// per relation, so the scope is part of the identity. -func findSeverityFixtureMatch( - findings []*ValidationError, want severityFixtureExpectation, -) *ValidationError { - for _, finding := range findings { - if finding.Metadata == nil || string(finding.Metadata.ErrorType) != want.ErrorType { - continue - } - - if finding.Unwrap() == nil { - continue - } - - causeScope := findingScope(finding) - if causeScope.ObjectType == want.Scope.ObjectType && - causeScope.Relation == want.Scope.Relation && - causeScope.Condition == want.Scope.Condition { - return finding - } - } - - return nil -} - -// TestSeverityFixturesAreNotInTheSharedCorpus keeps these keys out of tests/data. An -// unknown key there breaks the Java suite on deserialisation and the JS suite on its -// toMatchObject assertions; see the fixture file's header. -func TestSeverityFixturesAreNotInTheSharedCorpus(t *testing.T) { - t.Parallel() - - sharedCorpus := filepath.Join("..", "..", "..", "tests", "data", "dsl-semantic-validation-cases.yaml") - - contents, err := os.ReadFile(sharedCorpus) - require.NoError(t, err, "reading the shared corpus") - - var cases []map[string]any - require.NoError(t, yaml.Unmarshal(contents, &cases)) - - // Keys pkg/js and pkg/java have no field for. - goOnlyKeys := []string{"severity", "category", "critical", "sentinel", "scope"} - - for index, testCase := range cases { - for _, key := range goOnlyKeys { - _, present := testCase[key] - assert.Falsef(t, present, - "shared corpus case %d has Go-only key %q; it belongs in %s until "+ - "pkg/js and pkg/java can read it", index, key, severityFixtureFile) - } - - expectedErrors, ok := testCase["expected_errors"].([]any) - if !ok { - continue - } - - for _, raw := range expectedErrors { - expectedError, ok := raw.(map[string]any) - if !ok { - continue - } - - for _, key := range goOnlyKeys { - _, present := expectedError[key] - assert.Falsef(t, present, - "shared corpus case %d has Go-only key %q inside expected_errors", index, key) - } - } - } -} - -// TestEveryFixtureSentinelIsReal stops sentinelsByName from drifting into a map -// of names that no longer exist, which would make the fixtures silently skip. -func TestEveryFixtureSentinelIsReal(t *testing.T) { - t.Parallel() - - for name, sentinel := range sentinelsByName { - require.Errorf(t, sentinel, "%s resolves to a nil error", name) - } - - for _, fixture := range loadSeverityFixtures(t) { - for _, want := range fixture.Expected { - _, ok := sentinelsByName[want.Sentinel] - assert.Truef(t, ok, "fixture %q names unknown sentinel %q", fixture.Name, want.Sentinel) - } - } -} diff --git a/pkg/go/validation/severity_predicates_test.go b/pkg/go/validation/severity_predicates_test.go deleted file mode 100644 index 1dbd9bc9..00000000 --- a/pkg/go/validation/severity_predicates_test.go +++ /dev/null @@ -1,367 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// No validation emits a non-blocking severity yet: every errorInfoByType entry is -// SeverityError. The tests below build findings directly, which is the only way to -// pin the severity predicates while nothing emits one. - -func finding(severity fgaerrors.Severity, message string) *ValidationError { - return &ValidationError{ - Message: message, - Severity: severity, - Metadata: &ErrorMetadata{ErrorType: RelationNoEntrypoint}, - } -} - -func TestPredicatesCountBlockingOnly(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - findings []*ValidationError - wantHasErrors bool - wantCount int - wantAllCount int - }{ - "nothing at all": { - findings: nil, - wantHasErrors: false, - wantCount: 0, - wantAllCount: 0, - }, - "advisory only": { - findings: []*ValidationError{finding(fgaerrors.SeverityAdvisory, "check may answer differently")}, - wantHasErrors: false, - wantCount: 0, - wantAllCount: 1, - }, - "warning only": { - findings: []*ValidationError{finding(fgaerrors.SeverityWarning, "uses a construct a future version may reject")}, - wantHasErrors: false, - wantCount: 0, - wantAllCount: 1, - }, - "one error among non-blocking": { - findings: []*ValidationError{ - finding(fgaerrors.SeverityAdvisory, "advisory"), - finding(fgaerrors.SeverityError, "real error"), - finding(fgaerrors.SeverityWarning, "warning"), - }, - wantHasErrors: true, - wantCount: 1, - wantAllCount: 3, - }, - "severity unset counts as blocking": { - findings: []*ValidationError{{Message: "built by hand"}}, - wantHasErrors: true, - wantCount: 1, - wantAllCount: 1, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - validationErrors := NewValidationErrors(test.findings) - - assert.Equal(t, test.wantHasErrors, validationErrors.HasErrors()) - assert.Equal(t, test.wantCount, validationErrors.Count()) - assert.Equal(t, test.wantAllCount, validationErrors.CountAll()) - assert.Equal(t, test.wantAllCount > 0, validationErrors.HasFindings()) - assert.Len(t, validationErrors.GetErrors(), test.wantCount) - assert.Len(t, validationErrors.AllFindings(), test.wantAllCount) - }) - } -} - -// TestValidModelWithAdvisoryIsStillValid checks an advisory does not fail a model. -// An advisory describes a model that is correct, so reporting one must not -// invalidate it. -func TestValidModelWithAdvisoryIsStillValid(t *testing.T) { - t.Parallel() - - report := ValidationReport{ - ValidationErrors: NewValidationErrors([]*ValidationError{ - finding(fgaerrors.SeverityAdvisory, "a check against this model may answer differently"), - finding(fgaerrors.SeverityWarning, "this model uses a construct a future version may reject"), - }), - } - - assert.True(t, report.IsValid(), "warnings and advisories must not make a model invalid") - assert.True(t, report.ValidationErrors.HasFindings(), "but they must still be reported") - assert.Equal(t, "no validation errors", report.ValidationErrors.Error(), - "the error string describes blocking findings, and there are none") -} - -// TestErrorOrNilIsNilWhenNothingBlocks is the entry-point half of the same rule: -// a model whose only findings are warnings or advisories is valid, so err != nil -// means the model is invalid and not that something was reported. -func TestErrorOrNilIsNilWhenNothingBlocks(t *testing.T) { - t.Parallel() - - nonBlocking := NewValidationErrors([]*ValidationError{ - finding(fgaerrors.SeverityWarning, "this model uses a construct a future version may reject"), - finding(fgaerrors.SeverityAdvisory, "a check against this model may answer differently"), - }) - - // A literal nil, not a nil *ValidationErrors: the latter is a non-nil error - // however few findings it holds. - require.NoError(t, nonBlocking.ErrorOrNil()) - assert.True(t, nonBlocking.HasFindings(), "the findings are still there to be reported") -} - -func TestErrorOrNilCarriesEverythingWhenSomethingBlocks(t *testing.T) { - t.Parallel() - - blocking := NewValidationErrors([]*ValidationError{ - finding(fgaerrors.SeverityWarning, "warning"), - finding(fgaerrors.SeverityError, "boom"), - }) - - err := blocking.ErrorOrNil() - require.Error(t, err) - - var recovered *ValidationErrors - require.ErrorAs(t, err, &recovered) - assert.Len(t, recovered.AllFindings(), 2, "the non-blocking finding travels with the blocking one") -} - -// TestErrorOrNilOnANilCollection covers the nil receiver, since the entry points -// call it on whatever RunAllValidations handed back. -func TestErrorOrNilOnANilCollection(t *testing.T) { - t.Parallel() - - var nilCollection *ValidationErrors - - assert.NoError(t, nilCollection.ErrorOrNil()) -} - -// TestUnwrapReachesEveryFinding checks errors.Is sees a non-blocking finding too. -// Unwrap answers "was this condition reported", which is a different question from -// "does the model still validate". -func TestUnwrapReachesEveryFinding(t *testing.T) { - t.Parallel() - - warning := finding(fgaerrors.SeverityWarning, "warned") - warning.Cause = &fgaerrors.ErrRelation{ObjectType: "document", Relation: "viewer", Cause: fgaerrors.ErrNoEntrypoints} - - collection := NewValidationErrors([]*ValidationError{ - finding(fgaerrors.SeverityError, "boom"), - warning, - }) - - require.ErrorIs(t, collection, fgaerrors.ErrNoEntrypoints, - "the sentinel of a warning must still be reachable") - - var scoped *fgaerrors.ErrRelation - require.ErrorAs(t, collection, &scoped) - assert.Equal(t, "viewer", scoped.Relation) -} - -// TestUnwrapOfAnUnsetCauseIsNil checks a finding with no cause unwraps to a nil error -// rather than to a non-nil error holding nothing. -// -// Cause is an interface, so Unwrap converts one interface value to another. An unset -// Cause is a nil interface and converts to a nil error; a scope wrapping a nil -// sentinel would not, which is why WithSentinel yields nothing rather than building -// one. -func TestUnwrapOfAnUnsetCauseIsNil(t *testing.T) { - t.Parallel() - - unset := finding(fgaerrors.SeverityError, "no cause") - - require.Nil(t, unset.Cause) - require.NoError(t, unset.Unwrap()) - - // The path a code missing from errorInfoByType takes: no sentinel to wrap, so the - // collector stores nothing rather than a scope wrapping nil. - assert.Nil(t, fgaerrors.WithSentinel(&fgaerrors.ErrRelation{Relation: "viewer"}, nil)) - - collection := NewValidationErrors([]*ValidationError{unset}) - assert.NotErrorIs(t, collection, fgaerrors.ErrNoEntrypoints) -} - -// TestUnwrapSkipsNilFindings checks a directly-constructed collection holding a nil -// entry does not panic: a nil *ValidationError handed to errors.Is as a non-nil -// error would dereference nil on Unwrap. -func TestUnwrapSkipsNilFindings(t *testing.T) { - t.Parallel() - - collection := NewValidationErrors([]*ValidationError{nil, finding(fgaerrors.SeverityError, "boom")}) - - assert.Len(t, collection.Unwrap(), 1) - assert.NotErrorIs(t, collection, fgaerrors.ErrNoEntrypoints) -} - -func TestBlockingFindingMakesModelInvalid(t *testing.T) { - t.Parallel() - - report := ValidationReport{ - ValidationErrors: NewValidationErrors([]*ValidationError{ - finding(fgaerrors.SeverityAdvisory, "advisory"), - finding(fgaerrors.SeverityError, "boom"), - }), - } - - assert.False(t, report.IsValid()) - assert.Contains(t, report.ValidationErrors.Error(), "1 error occurred", - "the count must agree with Count(), not with len(Errors)") - assert.NotContains(t, report.ValidationErrors.Error(), "advisory") -} - -// TestCascadeGateIgnoresNonBlockingFindings checks the gate in RunAllValidations -// counts only blocking findings. If it counted advisories, one advisory raised early -// would skip every gated phase and hide the errors they would have found. -func TestCascadeGateIgnoresNonBlockingFindings(t *testing.T) { - t.Parallel() - - collector := NewValidationErrors(nil) - collector.Errors = append(collector.Errors, - finding(fgaerrors.SeverityAdvisory, "advisory"), - finding(fgaerrors.SeverityWarning, "warning"), - ) - - require.False(t, collector.HasErrors(), - "a collection holding only non-blocking findings must not close the cascade gate") - assert.Equal(t, 0, collector.Count()) - assert.Equal(t, 2, collector.CountAll()) - assert.Len(t, collector.AllFindings(), 2, - "the collection is the raw record and filters nothing") - - collector.Errors = append(collector.Errors, finding(fgaerrors.SeverityError, "real error")) - assert.True(t, collector.HasErrors(), "a blocking finding must close the gate") -} - -// TestSummarySplitsBySeverity checks the summary reports both totals, so a -// consumer can say "3 findings, 1 of which fails the model". -func TestSummarySplitsBySeverity(t *testing.T) { - t.Parallel() - - engine := &ValidationEngine{errs: NewValidationErrors(nil)} - engine.errs.Errors = append(engine.errs.Errors, - finding(fgaerrors.SeverityError, "error"), - finding(fgaerrors.SeverityWarning, "warning"), - finding(fgaerrors.SeverityAdvisory, "advisory"), - ) - - summary := engine.GetValidationSummary() - - assert.Equal(t, 1, summary.TotalErrors, "only blocking findings are errors") - assert.Equal(t, 3, summary.TotalFindings) - assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityError]) - assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityWarning]) - assert.Equal(t, 1, summary.FindingsBySeverity[fgaerrors.SeverityAdvisory]) - assert.Equal(t, 3, summary.ErrorsByType[RelationNoEntrypoint], - "the by-type breakdown covers every finding, so it sums to TotalFindings") -} - -// TestGetErrorsByTypeIgnoresSeverity checks the deliberate exception: the caller -// asked for a specific code, so filtering by severity as well would drop matches it -// explicitly requested. -func TestGetErrorsByTypeIgnoresSeverity(t *testing.T) { - t.Parallel() - - report := ValidationReport{ - ValidationErrors: NewValidationErrors([]*ValidationError{ - finding(fgaerrors.SeverityAdvisory, "advisory"), - finding(fgaerrors.SeverityError, "error"), - }), - } - - assert.Len(t, report.GetErrorsByType(RelationNoEntrypoint), 2) - assert.Empty(t, report.GetErrorsByType(UndefinedType)) -} - -// TestRealValidationStillFails checks the severity predicates do not stop real -// errors from counting. Every errorInfoByType entry is blocking, so real validation -// must still fail. -func TestRealValidationStillFails(t *testing.T) { - t.Parallel() - - validationErrors := validateDSL(t, `model - schema 1.1 -type document - relations - define viewer: [user] -`) - - require.True(t, validationErrors.HasErrors(), "an undefined type must still fail validation") - assert.Positive(t, validationErrors.Count()) - assert.Equal(t, validationErrors.CountAll(), validationErrors.Count(), - "nothing emits a non-blocking severity yet, so the two counts must agree") -} - -// TestPredicatesSurviveAWholeCollectionOfNothing pins the reads that a caller can -// reach without going through the collector: a nil collection, and one holding a nil -// finding. Every read goes through ValidationErrors.findings, so this covers the set. -func TestPredicatesSurviveAWholeCollectionOfNothing(t *testing.T) { - t.Parallel() - - var absent *ValidationErrors - - assert.False(t, absent.HasErrors()) - assert.False(t, absent.HasFindings()) - assert.Equal(t, 0, absent.Count()) - assert.Equal(t, 0, absent.CountAll()) - assert.Empty(t, absent.GetErrors()) - assert.Empty(t, absent.AllFindings()) - assert.Empty(t, absent.Unwrap()) - require.NoError(t, absent.ErrorOrNil()) - assert.Equal(t, "no validation errors", absent.Error()) - - // A nil finding is not a finding: every read drops it, so the counts agree with - // each other and nothing hands a caller an entry that dereferences nil. - held := NewValidationErrors([]*ValidationError{nil, finding(fgaerrors.SeverityError, "real")}) - - assert.True(t, held.HasErrors()) - assert.True(t, held.HasFindings()) - assert.Equal(t, 1, held.Count()) - assert.Equal(t, 1, held.CountAll(), "the nil entry is not a finding to count") - assert.Len(t, held.GetErrors(), 1) - assert.Len(t, held.AllFindings(), 1) - assert.Len(t, held.Unwrap(), 1) - assert.Contains(t, held.Error(), "real") - - // Every entry AllFindings returns is safe to dereference, which is why the nil is - // dropped rather than counted. - for _, f := range held.AllFindings() { - assert.Equal(t, fgaerrors.SeverityError, f.Severity) - assert.Contains(t, f.String(), "real") - } - - // A collection of nothing but nil reports nothing, rather than reporting a count - // while Unwrap and Error report none. - onlyNil := NewValidationErrors([]*ValidationError{nil}) - - assert.False(t, onlyNil.HasFindings(), "a nil entry is not something reported") - assert.Equal(t, 0, onlyNil.CountAll()) - assert.Empty(t, onlyNil.AllFindings()) - require.NoError(t, onlyNil.ErrorOrNil()) - - // Add is the other way in. - added := NewValidationErrors(nil) - added.Add(nil) - assert.False(t, added.HasFindings()) - assert.Equal(t, 0, added.CountAll()) - - // A zero report reaches a nil collection through IsValid. - var report ValidationReport - - assert.True(t, report.IsValid()) - assert.False(t, report.HasCriticalErrors()) - assert.Empty(t, report.GetErrorsByType(UndefinedType)) - - // GetErrorsByType reads the code off metadata, which a hand-built finding can omit. - withoutMetadata := ValidationReport{ - ValidationErrors: NewValidationErrors([]*ValidationError{nil, {Message: "no metadata"}}), - } - assert.Empty(t, withoutMetadata.GetErrorsByType(UndefinedType)) -} diff --git a/pkg/go/validation/source.go b/pkg/go/validation/source.go new file mode 100644 index 00000000..3e4af001 --- /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 `fs.add(invalidType(name).at(src, line))`. +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/testdata/severity-category-cases.yaml b/pkg/go/validation/testdata/severity-category-cases.yaml deleted file mode 100644 index de02a4a2..00000000 --- a/pkg/go/validation/testdata/severity-category-cases.yaml +++ /dev/null @@ -1,137 +0,0 @@ ---- -# Go-only fixtures for the severity, category and criticality that pkg/go attaches -# to each validation finding. -# -# They live here rather than in tests/data/dsl-semantic-validation-cases.yaml because -# that corpus is shared with pkg/js and pkg/java, and neither carries a severity or -# category concept yet. Adding the keys there breaks both suites: pkg/java reads the -# corpus through a bare YAMLMapper onto case classes with no @JsonIgnoreProperties, so -# Jackson rejects an unrecognised key outright, and pkg/js asserts each expected error -# with toMatchObject, which fails on an expected key the error object does not carry. -# If either picks these fields up, these cases are what the shared corpus should -# absorb. -# -# Fields: -# error_type — the slug, as it appears in metadata.errorType -# severity — error | warning | advisory; error means the model is invalid -# category — the part of the model the finding is about -# critical — the model as a whole is unusable, not just one relation -# sentinel — name of the errors.Is target in pkg/go/errors -# scope — which scope fields the wrapped cause must carry - -- name: undefined type in a type restriction - dsl: | - model - schema 1.1 - type document - relations - define viewer: [user] - expected: - - error_type: invalid-type - severity: error - category: object-type - critical: false - sentinel: ErrInvalidType - scope: - object_type: user - -- name: duplicate type definition - dsl: | - model - schema 1.1 - type user - type document - type document - expected: - - error_type: duplicated-error - severity: error - category: object-type - critical: true - sentinel: ErrDuplicateDefinition - scope: - object_type: document - -- name: relation with no entrypoint - dsl: | - model - schema 1.1 - type user - type document - relations - define viewer: writer - define writer: viewer - expected: - - error_type: relation-no-entry-point - severity: error - category: relation - critical: true - sentinel: ErrNoEntrypoints - scope: - object_type: document - relation: viewer - - error_type: relation-no-entry-point - severity: error - category: relation - critical: true - sentinel: ErrNoEntrypoints - scope: - object_type: document - relation: writer - -- name: condition defined but never referenced - dsl: | - model - schema 1.1 - type user - type document - relations - define viewer: [user] - - condition inRegion(x: string) { - x == "eu" - } - expected: - - error_type: condition-not-used - severity: error - category: condition - critical: false - sentinel: ErrConditionUnReferenced - scope: - condition: inRegion - -- name: reserved keyword as a type name - dsl: | - model - schema 1.1 - type user - type self - expected: - - error_type: reserved-type-keywords - severity: error - category: object-type - critical: false - sentinel: ErrReservedKeywords - scope: - object_type: self - -# A condition applied to a relation that the model never defines. Scoped to the -# relation it is applied to rather than to a definition of its own, which is what -# separates relation-condition from condition. -- name: condition applied to a relation is not defined - dsl: | - model - schema 1.1 - type user - type document - relations - define viewer: [user with inRegion] - expected: - - error_type: condition-not-defined - severity: error - category: relation-condition - critical: false - sentinel: ErrConditionUndefined - scope: - object_type: document - relation: viewer - condition: inRegion diff --git a/pkg/go/validation/validate.go b/pkg/go/validation/validate.go new file mode 100644 index 00000000..1e2a4054 --- /dev/null +++ b/pkg/go/validation/validate.go @@ -0,0 +1,65 @@ +package validation + +import ( + 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 is a Findings holding +// every finding in the order raised, which errors.As recovers: +// +// var findings validation.Findings +// if errors.As(err, &findings) { +// for _, f := range findings { ... } +// } +func ValidateDSL(model *openfgav1.AuthorizationModel, dsl string) error { + return validate(model, newSource(dsl)).Err() +} + +// 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{}).Err() +} + +// 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) Findings { + if model == nil { + return nil + } + + idx := newIndex(model) + + fs := validateSchemaVersion(model, src) + fs = append(fs, validateNames(model, src)...) + fs = append(fs, validateRelationReferences(idx, src)...) + + if len(fs) == 0 { + fs = append(fs, validateDuplicates(model, src)...) + } + + if len(fs) == 0 { + fs = append(fs, validateEntryPoints(idx, src)...) + fs = append(fs, validateTupleToUsersets(idx, src)...) + fs = append(fs, validateComplexOperations(idx, src)...) + fs = append(fs, validateWildcards(idx, src)...) + } + + fs = append(fs, validateMultiFile(model)...) + fs = append(fs, validateConditions(model, src)...) + + return fs +} diff --git a/pkg/go/validation/validate_test.go b/pkg/go/validation/validate_test.go new file mode 100644 index 00000000..83ece080 --- /dev/null +++ b/pkg/go/validation/validate_test.go @@ -0,0 +1,253 @@ +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 errors.As", 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) + + var findings Findings + require.ErrorAs(t, err, &findings) + 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)) + + var findings Findings + require.ErrorAs(t, err, &findings) + 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 := 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` + + var findings Findings + require.ErrorAs(t, ValidateDSL(mustParse(t, dsl), dsl), &findings) + 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 +}` + + var findings Findings + require.ErrorAs(t, ValidateDSL(mustParse(t, dsl), dsl), &findings) + 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 := 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 := 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 d67759f3..00000000 --- a/pkg/go/validation/validation_engine.go +++ /dev/null @@ -1,230 +0,0 @@ -package validation - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - - fgaerrors "github.com/openfga/language/pkg/go/errors" -) - -// ValidationEngine is the main entry point for all validation operations. -type ValidationEngine struct { - model *openfgav1.AuthorizationModel - lines []string - errs *ValidationErrors - // 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") - ve := &ValidationEngine{model: model, lines: lines, errs: NewValidationErrors(nil)} - if model != nil { - ve.semantic = NewSemanticValidator(model) - ve.condition = NewConditionValidator(model) - } - return ve -} - -// ValidateDSL runs every validation over model, using dslContent to resolve each -// finding's position in the source text. The model is the already-parsed proto, -// here and in ValidateJSON; neither parses anything. -// -// Returns nil for a valid model. Otherwise the error is a *ValidationErrors, which -// errors.As recovers to list every finding; see ValidationErrors.ErrorOrNil for why -// a model carrying only warnings is nil here, and CreateValidationReport for reaching -// those findings. -func ValidateDSL(model *openfgav1.AuthorizationModel, dslContent string, options *EngineOptions) error { - if options == nil { - options = DefaultEngineOptions() - } - return NewValidationEngine(model, dslContent).RunAllValidations(options).ErrorOrNil() -} - -// ValidateJSON runs every validation over a model that reached the caller as JSON, -// so without the DSL source text behind it. It takes the same parsed proto as -// ValidateDSL and decodes no JSON itself; the name matches pkg/js's validateJSON and -// pkg/java's ModelValidator.validateJson. -// -// With no source text to resolve positions against, findings carry a nil Line and -// Column. The messages, categories and metadata are what ValidateDSL reports for the -// same model. Returns nil for a valid model, as ValidateDSL does. -func ValidateJSON(model *openfgav1.AuthorizationModel, options *EngineOptions) error { - if options == nil { - options = DefaultEngineOptions() - } - return NewValidationEngine(model, "").RunAllValidations(options).ErrorOrNil() -} - -// 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. - ValidateSchemaVersion(ve.errs, ve.model, ve.lines) - ValidateNames(ve.errs, ve.model, ve.lines) - - // Relation-reference validation always runs. The phases that follow are - // gated on there being no blocking error 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. - // - // The gate counts blocking findings only, so a warning or advisory does not stop - // the later passes from finding an error that would invalidate the model. - if !options.SkipSemanticValidation { - validateRelationReferences(ve.errs, ve.semantic, ve.lines) - } - - if !ve.errs.HasErrors() { - ValidateDuplicates(ve.errs, ve.model, ve.lines) - } - - if !ve.errs.HasErrors() { - if !options.SkipSemanticValidation { - validateCyclesAndEntryPoints(ve.errs, ve.semantic, ve.lines) - validateTupleToUsersetRequirements(ve.errs, ve.semantic, ve.lines) - } - if !options.SkipComplexOperationValidation { - validateComplexOperations(ve.errs, ve.semantic, ve.lines) - } - if !options.SkipWildcardValidation { - validateWildcardUsage(ve.errs, 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 { - ValidateMultiFileConsistency(ve.errs, ve.model, ve.lines) - } - if !options.SkipConditionValidation { - validateConditionReferences(ve.errs, ve.condition, ve.lines) - ValidateConditionConsistency(ve.errs, ve.model, ve.lines) - validateUnusedConditions(ve.errs, ve.condition, ve.lines) - } - - return ve.errs -} - -// ValidateModel is ValidateDSL with the default options, which skip no phase. -func ValidateModel(model *openfgav1.AuthorizationModel, dslContent string) error { - return ValidateDSL(model, dslContent, DefaultEngineOptions()) -} - -// ValidateModelJSON is ValidateJSON with the default options. -func ValidateModelJSON(model *openfgav1.AuthorizationModel) error { - return ValidateJSON(model, DefaultEngineOptions()) -} - -func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { - errors := ve.errs.AllFindings() - summary := ValidationSummary{ - TotalErrors: ve.errs.Count(), - TotalFindings: ve.errs.CountAll(), - ErrorsByType: make(map[ValidationErrorType]int), - ErrorsByFile: make(map[string]int), - FindingsBySeverity: make(map[fgaerrors.Severity]int), - HasCriticalErrors: false, - } - for _, err := range errors { - if err == nil || err.Metadata == nil { - // The constructors always set metadata, 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]++ - } - summary.FindingsBySeverity[err.Severity]++ - if isCriticalErrorType(err.Metadata.ErrorType) { - summary.HasCriticalErrors = true - } - } - return summary -} - -// ValidationSummary provides a high-level overview of validation results. -// -// The breakdowns cover every finding, so they sum to TotalFindings, not TotalErrors. -type ValidationSummary struct { - // TotalErrors counts only the findings that make the model invalid. - TotalErrors int - - // TotalFindings counts everything reported, including warnings and advisories. - TotalFindings int - - ErrorsByType map[ValidationErrorType]int - ErrorsByFile map[string]int - - // FindingsBySeverity counts findings by severity. A finding with no severity set - // counts under SeverityUnspecified. - FindingsBySeverity map[fgaerrors.Severity]int - - HasCriticalErrors bool -} - -// 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 -} - -// IsValid reports whether the model is usable: no finding blocks it. Warnings and -// advisories leave it valid; HasFindings reports whether any were raised. -func (vr *ValidationReport) IsValid() bool { return !vr.ValidationErrors.HasErrors() } -func (vr *ValidationReport) HasCriticalErrors() bool { return vr.Summary.HasCriticalErrors } - -// GetErrorsByType returns findings of a given error type, blocking or not: the -// caller has named the code it wants, so filtering by severity as well would drop -// matches it asked for. -func (vr *ValidationReport) GetErrorsByType(errorType ValidationErrorType) []*ValidationError { - var matchingErrors []*ValidationError - for _, err := range vr.ValidationErrors.AllFindings() { - // The constructors always set metadata, but a directly-constructed finding - // need not have, and a code is only readable off metadata. - if err == nil || err.Metadata == nil { - continue - } - - 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 3043c284..00000000 --- a/pkg/go/validation/validation_engine_test.go +++ /dev/null @@ -1,618 +0,0 @@ -package validation - -import ( - "errors" - "fmt" - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fgaerrors "github.com/openfga/language/pkg/go/errors" - "github.com/openfga/language/pkg/go/transformer" -) - -// findingsFrom recovers the collection behind an error returned by a validation entry -// point. A nil error becomes an empty collection, so a test can read Count and -// GetErrors off the result either way. -func findingsFrom(err error) *ValidationErrors { - var validationErrors *ValidationErrors - if errors.As(err, &validationErrors) { - return validationErrors - } - - return NewValidationErrors(nil) -} - -// 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 -` - - // A valid model reports nothing, from every entry point. - assert.NoError(t, ValidateDSL(model, dslContent, DefaultEngineOptions())) - assert.NoError(t, ValidateJSON(model, DefaultEngineOptions())) - assert.NoError(t, ValidateModel(model, dslContent)) - assert.NoError(t, ValidateModelJSON(model)) - }) - - 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] -` - - findings := findingsFrom(ValidateDSL(model, dslContent, DefaultEngineOptions())) - assert.Positive(t, findings.Count()) - - // Check that we have various types of errors - errorList := findings.GetErrors() - errorTypes := make(map[ValidationErrorType]bool) - for _, err := range errorList { - errorTypes[err.Metadata.ErrorType] = true - } - - // Should have duplicate errors - assert.NotEmpty(t, errorTypes, "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) - normalErrorCount := findingsFrom(ValidateDSL(model, "", DefaultEngineOptions())).Count() - - // Skip semantic validation - options := &EngineOptions{ - SkipSemanticValidation: true, - } - skippedErrorCount := findingsFrom(ValidateDSL(model, "", options)).Count() - - // Should have fewer errors when semantic validation is skipped - assert.LessOrEqual(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, - } - - // Skipping complex-operation validation drops findings, never adds them, and - // leaves the surrounding phases running. - normalErrorCount := findingsFrom(ValidateDSL(model, "", DefaultEngineOptions())).Count() - skippedErrorCount := findingsFrom(ValidateDSL(model, "", options)).Count() - - assert.LessOrEqual(t, skippedErrorCount, normalErrorCount, - "Skipping complex operation validation should reduce or maintain error count") - }) -} - -// 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.Positive(t, summary.TotalErrors) - - // Test GetErrorsByType functionality - for errorType := range summary.ErrorsByType { - errorsOfType := report.GetErrorsByType(errorType) - assert.NotEmpty(t, errorsOfType, "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 -` - - findings := findingsFrom(ValidateDSL(model, dslContent, DefaultEngineOptions())) - - // This complex model should pass validation - if findings.Count() > 0 { - t.Logf("Validation errors found: %d", findings.Count()) - for _, err := range findings.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 - findings := findingsFrom(ValidateDSL(model, "", DefaultEngineOptions())) - - // Should complete validation in reasonable time - t.Logf("Large model validation completed with %d errors", findings.Count()) - - // Test JSON validation performance - jsonFindings := findingsFrom(ValidateJSON(model, DefaultEngineOptions())) - t.Logf("Large model JSON validation completed with %d errors", jsonFindings.Count()) - }) -} - -// TestEntryPointsReportFindingsThroughTheError pins what the four entry points -// return: nil for a valid model, and otherwise an error carrying every finding with -// its sentinel and its scope still reachable, which is what findingsFrom relies on. -func TestEntryPointsReportFindingsThroughTheError(t *testing.T) { - t.Parallel() - - const dsl = `model - schema 1.1 -type user -type document - relations - define viewer: [user, group] -` - - model, err := transformer.TransformDSLToProto(dsl) - require.NoError(t, err) - - validationErr := ValidateDSL(model, dsl, DefaultEngineOptions()) - require.Error(t, validationErr, "group is not defined, so this model must not validate") - - // errors.Is reaches each finding's sentinel through Unwrap() []error. - require.ErrorIs(t, validationErr, fgaerrors.ErrInvalidType) - - // errors.As reaches the scope by the same path. - var scoped *fgaerrors.ErrObjectType - require.ErrorAs(t, validationErr, &scoped) - assert.Equal(t, "group", scoped.ObjectType) - - // errors.As also recovers the collection itself, which is how a caller lists every - // finding rather than the first one errors.As stops at. - var collection *ValidationErrors - require.ErrorAs(t, validationErr, &collection) - assert.NotEmpty(t, collection.AllFindings()) -} - -// TestFindingOrderIsDeterministic checks that validating one model twice reports its -// findings in the same order. -// -// Relations and conditions reach every validation phase in a proto map, which has no -// order of its own. Ranging one directly ordered the findings by whatever the runtime -// handed back, so the same model produced four different orders across runs, and a -// caller printing the list or comparing it against a fixture saw it change under them. -func TestFindingOrderIsDeterministic(t *testing.T) { - t.Parallel() - - // The relation names sort in a different order than the symbols they report, so a - // run that happened to sort by message would not pass this. - const dsl = `model - schema 1.1 -type user -type document - relations - define alpha: missing_a - define beta: missing_b - define gamma: missing_c - define delta: missing_d -` - - model, err := transformer.TransformDSLToProto(dsl) - require.NoError(t, err) - - want := []string{ - "the relation `missing_a` does not exist.", - "the relation `missing_b` does not exist.", - "the relation `missing_d` does not exist.", - "the relation `missing_c` does not exist.", - } - - // Map iteration is randomized per range, so one passing run proves nothing; four - // keys admit four orders, which 100 runs would not agree on by chance. - for i := 0; i < 100; i++ { - messages := make([]string, 0, len(want)) - for _, finding := range findingsFrom(ValidateDSL(model, dsl, nil)).AllFindings() { - messages = append(messages, finding.Message) - } - - require.Equal(t, want, messages, "run %d reported the findings in a different order", i) - } -} - -// TestValidateJSONDiffersFromValidateDSLOnlyInPosition checks the two entry points -// take the same parsed proto and report the same findings, and that position is the -// only difference: ValidateDSL resolves line and column from the source text, and -// ValidateJSON leaves both nil. -func TestValidateJSONDiffersFromValidateDSLOnlyInPosition(t *testing.T) { - t.Parallel() - - const dsl = `model - schema 1.1 -type user -type document - relations - define viewer: [user, group] -` - - model, err := transformer.TransformDSLToProto(dsl) - require.NoError(t, err) - - fromDSL := findingsFrom(ValidateDSL(model, dsl, nil)).AllFindings() - fromJSON := findingsFrom(ValidateJSON(model, nil)).AllFindings() - - require.NotEmpty(t, fromDSL, "the model must produce a finding, or this compares two empty lists") - require.Len(t, fromJSON, len(fromDSL), "the two entry points must find the same problems") - - for i := range fromDSL { - assert.Equal(t, fromDSL[i].Message, fromJSON[i].Message) - assert.Equal(t, fromDSL[i].Severity, fromJSON[i].Severity) - assert.Equal(t, fromDSL[i].Category, fromJSON[i].Category) - assert.Equal(t, fromDSL[i].Metadata, fromJSON[i].Metadata) - - assert.NotNil(t, fromDSL[i].Line, "ValidateDSL has the source text, so it must resolve the line") - assert.NotNil(t, fromDSL[i].Column) - assert.Nil(t, fromJSON[i].Line, "ValidateJSON has no source text to resolve a line against") - assert.Nil(t, fromJSON[i].Column) - } -} diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go index 25d7e188..6a4bbadc 100644 --- a/pkg/go/validation/wildcard_validation.go +++ b/pkg/go/validation/wildcard_validation.go @@ -7,152 +7,125 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ValidateWildcardUsage validates wildcard relation usage rules. -func ValidateWildcardUsage(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateWildcardUsage(errs, 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) Findings { + var fs Findings -func validateWildcardUsage(errs *ValidationErrors, 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 } + + 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)) { - validateWildcardInRelation(errs, validator, typeDef.GetType(), relationName, - relationsMetadata[relationName], lines) - } - } -} + relationMetadata := relationsMetadata[relationName] + if relationMetadata == nil { + continue + } -func validateWildcardInRelation(errs *ValidationErrors, 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(errs, validator, typeRestriction, relationName, typeName, meta, lines, typeLineIndex) - // wildcard and explicit relation together is invalid - if typeRestriction.GetRelation() != "" { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - errs.Add(newInvalidWildcardUsageError(lines, invalidWildcardUsageArgs{ - typeName: typeRestriction.GetType(), - relationName: relationName, - parentTypeName: typeName, - reason: "wildcard cannot be used with specific relation", - meta: meta, - lineIndex: lineIndex, - })) + 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(errs *ValidationErrors, validator *SemanticValidator, - typeRestriction *openfgav1.RelationReference, relationName, typeName string, meta *Meta, lines []string, typeLineIndex *int) { - if !validator.TypeDefined(typeRestriction.GetType()) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - errs.Add(newUndefinedTypeError(lines, typeRestriction.GetType(), relationName, typeName, meta, lineIndex)) - } + return fs } -// ValidateTupleToUsersetRequirements validates tuple-to-userset usage requirements. -func ValidateTupleToUsersetRequirements(errs *ValidationErrors, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateTupleToUsersetRequirements(errs, 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) Findings { + var fs Findings -func validateTupleToUsersetRequirements(errs *ValidationErrors, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - for _, typeDef := range model.GetTypeDefinitions() { + for _, typeDef := range idx.model.GetTypeDefinitions() { relations := typeDef.GetRelations() for _, relationName := range slices.Sorted(maps.Keys(relations)) { - validateTupleToUsersetInUserset(errs, validator, typeDef.GetType(), relationName, - relations[relationName], lines) + fs = append(fs, tuplesetsIn(idx, src, typeDef.GetType(), relationName, relations[relationName])...) } } + + return fs } -func validateTupleToUsersetInUserset(errs *ValidationErrors, 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) Findings { if userset == nil { - return + return nil } + var fs Findings + if ttu := userset.GetTupleToUserset(); ttu != nil { - typeDef := validator.GetTypeDefinition(typeName) - meta := &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - validateTupleToUsersetOperation(errs, validator, typeName, relationName, ttu, meta, lines) + fs = fs.add(tuplesetNotAssignable(idx, src, typeName, relationName, ttu)) } + if union := userset.GetUnion(); union != nil { for _, child := range union.GetChild() { - validateTupleToUsersetInUserset(errs, 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(errs, validator, typeName, relationName, child, lines) + fs = append(fs, tuplesetsIn(idx, src, typeName, relationName, child)...) } } + if diff := userset.GetDifference(); diff != nil { - validateTupleToUsersetInUserset(errs, validator, typeName, relationName, diff.GetBase(), lines) - validateTupleToUsersetInUserset(errs, 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(errs *ValidationErrors, 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(errs, validator, typeName, tuplesetRelation, relationName, meta, lines) -} -func validateTuplesetDirectAssignment(errs *ValidationErrors, 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) - errs.Add(newTuplesetNotDirectError(lines, tuplesetRelation, typeName, parentRelation, meta, lineIndex)) - } - } + typeDef := idx.typeDef(typeName) + + relationMetadata, ok := typeDef.GetMetadata().GetRelations()[tuplesetRelation] + if !ok || len(relationMetadata.GetDirectlyRelatedUserTypes()) > 0 { + return nil } + + file, module := typeMeta(typeDef) + line := src.relationLine(relationName, -1) + + 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 51be6b98..680ec97d 100644 --- a/pkg/go/validation/yaml_integration_test.go +++ b/pkg/go/validation/yaml_integration_test.go @@ -63,14 +63,14 @@ func TestCompareWithCorpus(t *testing.T) { }, } - finding := func(mutate func(*ValidationError)) *ValidationError { - found := &ValidationError{ + 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: &ErrorMetadata{ - Symbol: "viewer", - ErrorType: MissingDefinition, + Metadata: Metadata{ + Symbol: "viewer", + Kind: MissingDefinition, }, } if mutate != nil { @@ -83,13 +83,13 @@ func TestCompareWithCorpus(t *testing.T) { tests := []struct { name string expected []YAMLExpectedError - findings []*ValidationError + findings Findings problems int }{ { name: "match", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(nil)}, + findings: Findings{finding(nil)}, }, { name: "no errors expected and none found", @@ -102,7 +102,7 @@ func TestCompareWithCorpus(t *testing.T) { // unmatched and the finding was not expected. name: "message is longer than the corpus states", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { + findings: Findings{finding(func(f *Finding) { f.Message += " Did you mean `view`?" })}, problems: 2, @@ -110,7 +110,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong line", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { + findings: Findings{finding(func(f *Finding) { f.Line = &Range{Start: 5, End: 5} })}, problems: 1, @@ -118,7 +118,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "line end differs", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { + findings: Findings{finding(func(f *Finding) { f.Line = &Range{Start: 4, End: 6} })}, problems: 1, @@ -126,7 +126,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "no position at all", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { + findings: Findings{finding(func(f *Finding) { f.Line, f.Column = nil, nil })}, problems: 1, @@ -134,7 +134,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong column", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { + findings: Findings{finding(func(f *Finding) { f.Column = &Range{Start: 12, End: 17} })}, problems: 1, @@ -142,7 +142,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong symbol", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { + findings: Findings{finding(func(f *Finding) { f.Metadata.Symbol = "editor" })}, problems: 1, @@ -150,29 +150,21 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong error type", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { - f.Metadata.ErrorType = UndefinedRelation - })}, - problems: 1, - }, - { - name: "no metadata", - expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(func(f *ValidationError) { - f.Metadata = nil + findings: Findings{finding(func(f *Finding) { + f.Metadata.Kind = UndefinedRelation })}, problems: 1, }, { name: "one finding does not satisfy two expectations", expected: []YAMLExpectedError{expected, expected}, - findings: []*ValidationError{finding(nil)}, + findings: Findings{finding(nil)}, problems: 1, }, { name: "finding the corpus does not expect", expected: []YAMLExpectedError{expected}, - findings: []*ValidationError{finding(nil), finding(func(f *ValidationError) { + findings: Findings{finding(nil), finding(func(f *Finding) { f.Message = "the relation `editor` does not exist." })}, problems: 1, @@ -180,7 +172,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "position the corpus leaves out is not compared", expected: []YAMLExpectedError{{Message: expected.Message}}, - findings: []*ValidationError{finding(func(f *ValidationError) { + findings: Findings{finding(func(f *Finding) { f.Line, f.Column = nil, nil })}, }, @@ -190,7 +182,7 @@ func TestCompareWithCorpus(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - result := compareWithCorpus(test.expected, NewValidationErrors(test.findings)) + result := compareWithCorpus(test.expected, test.findings) require.Len(t, result.Problems, test.problems, "problems: %v", result.Problems) diff --git a/pkg/go/validation/yaml_test_integration_test.go b/pkg/go/validation/yaml_test_integration_test.go index 29075109..578120d1 100644 --- a/pkg/go/validation/yaml_test_integration_test.go +++ b/pkg/go/validation/yaml_test_integration_test.go @@ -1,6 +1,7 @@ package validation import ( + "errors" "fmt" "os" "path/filepath" @@ -19,6 +20,14 @@ const ( corpusError = "ERROR" ) +// findingsOf recovers the findings behind a validation error; nil in, none out. +func findingsOf(err error) Findings { + var findings Findings + errors.As(err, &findings) + + return findings +} + // YAMLTestCase is one case from the shared validation corpus under tests/data. type YAMLTestCase struct { Name string `yaml:"name"` @@ -46,8 +55,7 @@ type YAMLRange struct { } // YAMLErrorMetadata is the metadata a corpus case pins: the offending symbol and the -// error type. Severity and category are this package's own classification, the corpus -// states neither, and the severity fixtures cover them instead. +// error type. type YAMLErrorMetadata struct { Symbol string `yaml:"symbol,omitempty"` ErrorType string `yaml:"errorType,omitempty"` @@ -120,25 +128,20 @@ func (runner *YAMLTestRunner) RunTestCase(testCase YAMLTestCase) *YAMLTestResult } } - return compareWithCorpus(testCase.ExpectedErrors, findingsFrom(ValidateDSL(model, testCase.DSL, DefaultEngineOptions()))) + return compareWithCorpus(testCase.ExpectedErrors, findingsOf(ValidateDSL(model, testCase.DSL))) } // compareWithCorpus pairs each expected error with a distinct finding, so a case // expecting two errors is not satisfied by one finding that matches both. -// -// The blocking findings are what take part: the corpus states the errors that make a -// model invalid and carries no severity of its own, so a warning is neither expected -// nor unexpected here. -func compareWithCorpus(expectedErrors []YAMLExpectedError, findings *ValidationErrors) *YAMLTestResult { +func compareWithCorpus(expectedErrors []YAMLExpectedError, findings Findings) *YAMLTestResult { result := &YAMLTestResult{} - blocking := findings.GetErrors() - claimed := make([]bool, len(blocking)) + 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 blocking { + for j, finding := range findings { if !claimed[j] && describeMismatch(expected, finding) == "" { claimed[j], matched[i] = true, true @@ -157,7 +160,7 @@ func compareWithCorpus(expectedErrors []YAMLExpectedError, findings *ValidationE paired := false - for j, finding := range blocking { + for j, finding := range findings { if !claimed[j] && finding.Message == expected.Message { claimed[j], paired = true, true result.Problems = append(result.Problems, @@ -173,7 +176,7 @@ func compareWithCorpus(expectedErrors []YAMLExpectedError, findings *ValidationE } } - for j, finding := range blocking { + for j, finding := range findings { if !claimed[j] { result.Problems = append(result.Problems, fmt.Sprintf("unexpected finding %s", describeFinding(finding))) } @@ -194,27 +197,24 @@ func compareWithCorpus(expectedErrors []YAMLExpectedError, findings *ValidationE // 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 *ValidationError) string { +func describeMismatch(expected YAMLExpectedError, finding *Finding) string { if finding.Message != expected.Message { return fmt.Sprintf("message %q, want %q", finding.Message, expected.Message) } - if finding.Metadata == nil { - return "no metadata" - } - if expected.Metadata.ErrorType != "" && - string(finding.Metadata.ErrorType) != expected.Metadata.ErrorType { - return fmt.Sprintf("errorType %q, want %q", finding.Metadata.ErrorType, expected.Metadata.ErrorType) + string(finding.Metadata.Kind) != expected.Metadata.ErrorType { + return fmt.Sprintf("errorType %q, want %q", finding.Metadata.Kind, expected.Metadata.ErrorType) } if expected.Metadata.Symbol != "" && finding.Metadata.Symbol != expected.Metadata.Symbol { return fmt.Sprintf("symbol %q, want %q", finding.Metadata.Symbol, expected.Metadata.Symbol) } - // A position the corpus states has to be reached, both ends of it. Letting a - // finding without one through would pass a finding that resolved to nowhere in the - // source, which is how the schema line lookup went unnoticed. + // 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 } @@ -243,13 +243,9 @@ func describeExpected(expected YAMLExpectedError) string { describePosition(rangeOf(expected.Line), rangeOf(expected.Column))) } -func describeFinding(finding *ValidationError) string { - errorType := ValidationErrorType("") - if finding.Metadata != nil { - errorType = finding.Metadata.ErrorType - } - - return fmt.Sprintf("%q [%s]%s", finding.Message, errorType, describePosition(finding.Line, finding.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 { From 07a329f55e3070158d8b8ab7380ba8e4b94af1d1 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Tue, 1 Sep 2026 17:13:19 +0530 Subject: [PATCH 7/8] docs(tests): drop stale severity note from shared corpus header The corpus header still said pkg/go attaches severity, category and criticality to each finding and that those expectations live in pkg/go/validation/testdata/severity-category-cases.yaml. The findings refactor removed that classification and that file, so the note described code and a path that no longer exist, in a file shared with pkg/js and pkg/java. The shared-field paragraph above it is unchanged and still correct. --- tests/data/dsl-semantic-validation-cases.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/data/dsl-semantic-validation-cases.yaml b/tests/data/dsl-semantic-validation-cases.yaml index d7a53a42..b53b5431 100644 --- a/tests/data/dsl-semantic-validation-cases.yaml +++ b/tests/data/dsl-semantic-validation-cases.yaml @@ -7,10 +7,6 @@ # 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. -# -# pkg/go attaches severity, category and criticality to each finding. Those -# expectations live in pkg/go/validation/testdata/severity-category-cases.yaml until -# the other implementations have the fields, and a test keeps them out of this file. - name: model 1.1 diff in exclusion not valid and spaces are reflected correctly in error messages dsl: | model From d3186db621cccddc33ccc3902688927f898904d6 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 3 Sep 2026 09:05:52 +0530 Subject: [PATCH 8/8] refactor(validation): return errors from phases, drop the Findings type Each validation phase now returns an error rather than appending to a Findings slice. A phase joins its findings with errors.Join, validate joins the phases the same way, and a single joinFindings boundary is the one place a nil *Finding is filtered out. ExtractAllAs[E error] walks the joined error tree and returns every finding in the order errors.Join laid them down, replacing the Findings type and its errors.As recovery. The corpus output is unchanged. --- .../complex_operation_validation.go | 20 +-- pkg/go/validation/condition_validation.go | 8 +- pkg/go/validation/cycle_detection.go | 6 +- .../validation/cycle_detection_stress_test.go | 4 +- pkg/go/validation/cycle_detection_test.go | 6 +- pkg/go/validation/duplicate_detection.go | 14 +- pkg/go/validation/findings.go | 100 +++++++------ pkg/go/validation/findings_test.go | 137 ++++++++++-------- pkg/go/validation/multi_file_validation.go | 6 +- pkg/go/validation/name_validation.go | 12 +- pkg/go/validation/name_validation_test.go | 4 +- pkg/go/validation/schema_validation.go | 8 +- pkg/go/validation/schema_validation_test.go | 12 +- pkg/go/validation/semantic_validation.go | 22 +-- pkg/go/validation/source.go | 2 +- pkg/go/validation/validate.go | 50 ++++--- pkg/go/validation/validate_test.go | 27 ++-- pkg/go/validation/wildcard_validation.go | 18 +-- pkg/go/validation/yaml_integration_test.go | 24 +-- .../validation/yaml_test_integration_test.go | 10 +- 20 files changed, 254 insertions(+), 236 deletions(-) diff --git a/pkg/go/validation/complex_operation_validation.go b/pkg/go/validation/complex_operation_validation.go index f74e5281..5b90d814 100644 --- a/pkg/go/validation/complex_operation_validation.go +++ b/pkg/go/validation/complex_operation_validation.go @@ -11,8 +11,8 @@ import ( // 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) Findings { - var fs Findings +func validateComplexOperations(idx *index, src source) error { + var fs []*Finding for _, typeDef := range idx.model.GetTypeDefinitions() { relations := typeDef.GetRelations() @@ -22,19 +22,19 @@ func validateComplexOperations(idx *index, src source) Findings { } } - return fs + return joinFindings(fs...) } // 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) Findings { + userset *openfgav1.Userset, visited map[string]bool) []*Finding { if userset == nil { return nil } - var fs Findings + var fs []*Finding if union := userset.GetUnion(); union != nil && len(union.GetChild()) > 0 { fs = append(fs, redundantUnionMembersIn(idx, src, typeName, relationName, union)...) @@ -55,7 +55,7 @@ func operationsIn(idx *index, src source, typeName, relationName string, if diff := userset.GetDifference(); diff != nil { fs = append(fs, operationsIn(idx, src, typeName, relationName, diff.GetBase(), visited)...) fs = append(fs, operationsIn(idx, src, typeName, relationName, diff.GetSubtract(), visited)...) - fs = fs.add(emptyDifferenceIn(idx, src, typeName, relationName, diff)) + fs = append(fs, emptyDifferenceIn(idx, src, typeName, relationName, diff)) } if ttu := userset.GetTupleToUserset(); ttu != nil { @@ -76,8 +76,8 @@ func operationsIn(idx *index, src source, typeName, relationName string, // redundantUnionMembersIn flags a union member repeated within one union. func redundantUnionMembersIn(idx *index, src source, typeName, relationName string, - union *openfgav1.Usersets) Findings { - var fs Findings + union *openfgav1.Usersets) []*Finding { + var fs []*Finding seen := make(map[string]bool) @@ -102,7 +102,7 @@ func redundantUnionMembersIn(idx *index, src source, typeName, relationName stri // impossibleIntersectionsIn flags an intersection whose direct-assignment // members can never agree. func impossibleIntersectionsIn(idx *index, src source, typeName, relationName string, - intersection *openfgav1.Usersets) Findings { + intersection *openfgav1.Usersets) []*Finding { restrictions := make([]string, 0) for _, child := range intersection.GetChild() { @@ -127,7 +127,7 @@ func impossibleIntersectionsIn(idx *index, src source, typeName, relationName st line := src.relationLine(relationName, -1) file, module := typeMeta(idx.typeDef(typeName)) - return Findings{impossibleIntersection(relationName, typeName, restrictions).at(src, line).in(file, module)} + return []*Finding{impossibleIntersection(relationName, typeName, restrictions).at(src, line).in(file, module)} } // emptyDifferenceIn flags a difference subtracting an operand from itself, diff --git a/pkg/go/validation/condition_validation.go b/pkg/go/validation/condition_validation.go index 7074e359..960aab81 100644 --- a/pkg/go/validation/condition_validation.go +++ b/pkg/go/validation/condition_validation.go @@ -17,7 +17,7 @@ type conditionUse struct { // 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) Findings { +func validateConditions(model *openfgav1.AuthorizationModel, src source) error { uses := conditionUses(model) fs := undefinedConditions(model, src, uses) @@ -45,7 +45,7 @@ func validateConditions(model *openfgav1.AuthorizationModel, src source) Finding fs = append(fs, unusedCondition(conditionName).at(src, src.conditionLine(conditionName)).in(file, module)) } - return fs + return joinFindings(fs...) } // conditionUses collects where each condition is referenced, in the order the @@ -74,8 +74,8 @@ func conditionUses(model *openfgav1.AuthorizationModel) map[string][]conditionUs // 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) Findings { - var fs Findings + uses map[string][]conditionUse) []*Finding { + var fs []*Finding defined := model.GetConditions() diff --git a/pkg/go/validation/cycle_detection.go b/pkg/go/validation/cycle_detection.go index 0f488f95..dda23160 100644 --- a/pkg/go/validation/cycle_detection.go +++ b/pkg/go/validation/cycle_detection.go @@ -16,8 +16,8 @@ type entryPointResult struct { // 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) Findings { - var fs Findings +func validateEntryPoints(idx *index, src source) error { + var fs []*Finding for _, typeDef := range idx.model.GetTypeDefinitions() { relations := typeDef.GetRelations() @@ -47,7 +47,7 @@ func validateEntryPoints(idx *index, src source) Findings { } } - return fs + return joinFindings(fs...) } // hasEntryPointOrLoop determines whether a rewrite reaches a concrete entry diff --git a/pkg/go/validation/cycle_detection_stress_test.go b/pkg/go/validation/cycle_detection_stress_test.go index 11bd1d39..9b00ed0f 100644 --- a/pkg/go/validation/cycle_detection_stress_test.go +++ b/pkg/go/validation/cycle_detection_stress_test.go @@ -44,7 +44,7 @@ func buildWideUnionDSL(width int) string { } // entryPointsFor transforms the DSL and runs the entry-point phase alone. -func entryPointsFor(t *testing.T, dsl string) Findings { +func entryPointsFor(t *testing.T, dsl string) []*Finding { t.Helper() model, err := transformer.TransformDSLToProto(dsl) @@ -52,7 +52,7 @@ func entryPointsFor(t *testing.T, dsl string) Findings { t.Fatalf("failed to transform DSL: %v", err) } - return validateEntryPoints(newIndex(model), newSource(dsl)) + return ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), newSource(dsl))) } // TestCycleDetection_DeepChainTerminatesWithEntry verifies a long linear chain diff --git a/pkg/go/validation/cycle_detection_test.go b/pkg/go/validation/cycle_detection_test.go index 7ebf04cf..fbb8820e 100644 --- a/pkg/go/validation/cycle_detection_test.go +++ b/pkg/go/validation/cycle_detection_test.go @@ -37,7 +37,7 @@ func TestValidateEntryPoints(t *testing.T) { }, } - findings := validateEntryPoints(newIndex(model), source{}) + findings := ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{})) // Each relation is impossible: one finding per relation, all RelationNoEntrypoint. assert.Len(t, findings, 2) @@ -77,7 +77,7 @@ func TestValidateEntryPoints(t *testing.T) { }, } - assert.Empty(t, validateEntryPoints(newIndex(model), source{})) + assert.Empty(t, ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{}))) }) t.Run("Computed chain terminating in a direct assignment is reachable", func(t *testing.T) { @@ -101,7 +101,7 @@ func TestValidateEntryPoints(t *testing.T) { } // All three relations resolve to owner's direct assignment. - assert.Empty(t, validateEntryPoints(newIndex(model), source{})) + assert.Empty(t, ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{}))) }) } diff --git a/pkg/go/validation/duplicate_detection.go b/pkg/go/validation/duplicate_detection.go index e061ccd4..45555c41 100644 --- a/pkg/go/validation/duplicate_detection.go +++ b/pkg/go/validation/duplicate_detection.go @@ -10,8 +10,8 @@ import ( // 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) Findings { - var fs Findings +func validateDuplicates(model *openfgav1.AuthorizationModel, src source) error { + var fs []*Finding seenTypes := make(map[string]bool) @@ -39,19 +39,19 @@ func validateDuplicates(model *openfgav1.AuthorizationModel, src source) Finding } } - return fs + return joinFindings(fs...) } // 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) Findings { + relationName string, typeDef *openfgav1.TypeDefinition, typeLine int) []*Finding { if relationMetadata == nil { return nil } - var fs Findings + var fs []*Finding typeName := typeDef.GetType() file, module := typeMeta(typeDef) @@ -87,7 +87,7 @@ func duplicateRestrictionsIn(src source, relationMetadata *openfgav1.RelationMet // 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) Findings { + relationName string, typeLine int) []*Finding { relation, ok := typeDef.GetRelations()[relationName] if !ok { return nil @@ -95,7 +95,7 @@ func duplicateOperandsIn(src source, typeDef *openfgav1.TypeDefinition, file, module := relationMeta(typeDef, relationName) - var fs Findings + var fs []*Finding raise := func(operand string) { line := src.relationLine(relationName, typeLine) diff --git a/pkg/go/validation/findings.go b/pkg/go/validation/findings.go index b0394256..66278307 100644 --- a/pkg/go/validation/findings.go +++ b/pkg/go/validation/findings.go @@ -5,8 +5,8 @@ package validation import ( + "errors" "fmt" - "strings" ) // Kind is a finding's machine-readable code, the wire `errorType`. The string @@ -83,8 +83,8 @@ type Finding struct { Metadata Metadata `json:"metadata"` } -// Error implements the error interface, so a single finding recovered with -// errors.As prints like one. +// 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) @@ -104,58 +104,56 @@ func (f *Finding) in(file, module string) *Finding { return f } -// Findings is every diagnostic raised for one model, in the order raised. It -// follows go/scanner.ErrorList: the slice is the collection and, when -// non-empty, the error. -// -//nolint:errname // named for what it holds, as go/scanner.ErrorList is -type Findings []*Finding - -// add appends f when it is a finding; a nil *Finding means nothing was found. -func (fs Findings) add(f *Finding) Findings { - if f == nil { - return fs - } - - return append(fs, f) -} - -// Error implements the error interface. -func (fs Findings) Error() string { - if len(fs) == 0 { - return "no validation errors" - } - - plural := "" - if len(fs) > 1 { - plural = "s" +// 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) + } } - messages := make([]string, 0, len(fs)) - for _, f := range fs { - messages = append(messages, f.Error()) - } - - return fmt.Sprintf("%d error%s occurred:\n\t* %s\n\n", len(fs), plural, strings.Join(messages, "\n\t* ")) + return errors.Join(errs...) } -// Unwrap returns each finding, so errors.As reaches one through the collection. -func (fs Findings) Unwrap() []error { - errs := make([]error, 0, len(fs)) - for _, f := range fs { - errs = append(errs, f) - } - - return errs -} - -// Err returns the collection as an error, or nil when nothing was found. It is -// the one place a Findings becomes an error, so a caller never receives a -// non-nil error holding an empty collection. -func (fs Findings) Err() error { - if len(fs) == 0 { - return nil +// 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 fs + return found } diff --git a/pkg/go/validation/findings_test.go b/pkg/go/validation/findings_test.go index 9b066a57..70534f3b 100644 --- a/pkg/go/validation/findings_test.go +++ b/pkg/go/validation/findings_test.go @@ -1,6 +1,8 @@ package validation import ( + "errors" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -31,112 +33,123 @@ func TestFindingError(t *testing.T) { }) } -func TestFindingsError(t *testing.T) { +func TestFindingIn(t *testing.T) { t.Parallel() - t.Run("empty", func(t *testing.T) { - t.Parallel() - assert.Equal(t, "no validation errors", Findings{}.Error()) - }) - - t.Run("one finding", func(t *testing.T) { + t.Run("stamps file and module", func(t *testing.T) { t.Parallel() - findings := Findings{{Message: "first"}} + finding := (&Finding{}).in("core.fga", "core") - assert.Equal(t, "1 error occurred:\n\t* validation error: first\n\n", findings.Error()) + assert.Equal(t, "core.fga", finding.File) + assert.Equal(t, "core", finding.Metadata.Module) }) - t.Run("two findings pluralize", func(t *testing.T) { + t.Run("nil finding stays nil", func(t *testing.T) { t.Parallel() - findings := Findings{{Message: "first"}, {Message: "second"}} - - assert.Equal(t, "2 errors occurred:\n\t* validation error: first\n\t* validation error: second\n\n", - findings.Error()) + var finding *Finding + assert.Nil(t, finding.in("core.fga", "core")) }) } -func TestFindingsErr(t *testing.T) { +func TestJoinFindings(t *testing.T) { t.Parallel() - t.Run("nil for no findings", func(t *testing.T) { + t.Run("nil when there is nothing to report", func(t *testing.T) { t.Parallel() - require.NoError(t, Findings(nil).Err()) - require.NoError(t, Findings{}.Err()) + require.NoError(t, joinFindings()) + require.NoError(t, joinFindings(nil, nil)) }) - t.Run("the collection itself otherwise", func(t *testing.T) { + t.Run("drops nil findings", func(t *testing.T) { t.Parallel() - findings := Findings{{Message: "boom"}} - err := findings.Err() - + err := joinFindings(nil, &Finding{Message: "boom"}, nil) require.Error(t, err) - var recovered Findings - require.ErrorAs(t, err, &recovered) - assert.Len(t, recovered, 1) + found := ExtractAllAs[*Finding](err) + require.Len(t, found, 1) + assert.Equal(t, "boom", found[0].Message) }) -} - -func TestFindingsUnwrap(t *testing.T) { - t.Parallel() - first := &Finding{Message: "first", Metadata: Metadata{Kind: InvalidName}} - second := &Finding{Message: "second", Metadata: Metadata{Kind: DuplicatedError}} - err := Findings{first, second}.Err() + t.Run("keeps the order it is given", func(t *testing.T) { + t.Parallel() - // errors.As walks Unwrap() []error and stops at the first finding. - var finding *Finding - require.ErrorAs(t, err, &finding) - assert.Same(t, first, finding) + err := joinFindings(&Finding{Message: "first"}, &Finding{Message: "second"}) - require.ErrorIs(t, err, error(first)) - require.ErrorIs(t, err, error(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 TestFindingsAdd(t *testing.T) { +func TestExtractAllAs(t *testing.T) { t.Parallel() - var findings Findings + t.Run("nil error yields nothing", func(t *testing.T) { + t.Parallel() - findings = findings.add(nil) - assert.Empty(t, findings, "a nil finding is nothing found") + assert.Empty(t, ExtractAllAs[*Finding](nil)) + }) - findings = findings.add(&Finding{Message: "found"}) - assert.Len(t, findings, 1) -} + t.Run("recovers findings from a nested tree in order", func(t *testing.T) { + t.Parallel() -func TestFindingIn(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"}) - t.Run("stamps file and module", func(t *testing.T) { - t.Parallel() + 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, + }) + }) - finding := (&Finding{}).in("core.fga", "core") + t.Run("terminates on a leaf that is neither the target nor a wrapper", func(t *testing.T) { + t.Parallel() - assert.Equal(t, "core.fga", finding.File) - assert.Equal(t, "core", finding.Metadata.Module) + // 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("nil finding stays nil", func(t *testing.T) { + t.Run("descends through a single-error wrapper", func(t *testing.T) { t.Parallel() - var finding *Finding - assert.Nil(t, finding.in("core.fga", "core")) + found := ExtractAllAs[*Finding](fmt.Errorf("context: %w", &Finding{Message: "wrapped"})) + require.Len(t, found, 1) + assert.Equal(t, "wrapped", found[0].Message) }) } -// TestFindingsAsError pins the boundary contract: a validation error is always -// a Findings, and errors.As is the documented way back to the findings. -func TestFindingsAsError(t *testing.T) { +// 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() - err := Findings{{Message: "boom", Metadata: Metadata{Kind: InvalidName, Symbol: "x"}}}.Err() + 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 findings Findings - require.ErrorAs(t, err, &findings) - assert.Equal(t, InvalidName, findings[0].Metadata.Kind) + 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/multi_file_validation.go b/pkg/go/validation/multi_file_validation.go index 46988f7c..9603d3b4 100644 --- a/pkg/go/validation/multi_file_validation.go +++ b/pkg/go/validation/multi_file_validation.go @@ -11,8 +11,8 @@ import ( // 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) Findings { - var fs Findings +func validateMultiFile(model *openfgav1.AuthorizationModel) error { + var fs []*Finding files := modulesByFile(model) for _, file := range files.keys { @@ -21,7 +21,7 @@ func validateMultiFile(model *openfgav1.AuthorizationModel) Findings { } } - return fs + return joinFindings(fs...) } // orderedGroups records a one-to-many mapping, keeping both the keys and each diff --git a/pkg/go/validation/name_validation.go b/pkg/go/validation/name_validation.go index 31ee8cca..9c60925f 100644 --- a/pkg/go/validation/name_validation.go +++ b/pkg/go/validation/name_validation.go @@ -65,8 +65,8 @@ func validateConditionName(name string) *Finding { // 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(model *openfgav1.AuthorizationModel, src source) Findings { - var fs Findings +func validateNames(model *openfgav1.AuthorizationModel, src source) error { + var fs []*Finding for _, typeDef := range model.GetTypeDefinitions() { typeName := typeDef.GetType() @@ -78,14 +78,14 @@ func validateNames(model *openfgav1.AuthorizationModel, src source) Findings { module := typeDef.GetMetadata().GetModule() typeLine := src.typeLine(typeName) - fs = fs.add(validateTypeName(typeName).at(src, typeLine).in(file, module)) + fs = append(fs, validateTypeName(typeName).at(src, typeLine).in(file, module)) // 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 = fs.add(validateRelationName(relationName, typeName).at(src, relationLine).in(file, module)) + fs = append(fs, validateRelationName(relationName, typeName).at(src, relationLine).in(file, module)) } } @@ -95,8 +95,8 @@ func validateNames(model *openfgav1.AuthorizationModel, src source) Findings { file := condition.GetMetadata().GetSourceInfo().GetFile() module := condition.GetMetadata().GetModule() - fs = fs.add(validateConditionName(conditionName).at(src, src.conditionLine(conditionName)).in(file, module)) + fs = append(fs, validateConditionName(conditionName).at(src, src.conditionLine(conditionName)).in(file, module)) } - return fs + return joinFindings(fs...) } diff --git a/pkg/go/validation/name_validation_test.go b/pkg/go/validation/name_validation_test.go index 5ea62325..024918ad 100644 --- a/pkg/go/validation/name_validation_test.go +++ b/pkg/go/validation/name_validation_test.go @@ -97,7 +97,7 @@ func TestValidateNames(t *testing.T) { dsl := "model\n schema 1.1\ntype self\n relations\n define this: [self]" model := modelWithRelations(t, "self", "this") - findings := validateNames(model, newSource(dsl)) + findings := ExtractAllAs[*Finding](validateNames(model, newSource(dsl))) require.Len(t, findings, 2) @@ -113,7 +113,7 @@ func TestValidateNames(t *testing.T) { t.Run("no source text means no positions", func(t *testing.T) { t.Parallel() - findings := validateNames(modelWithRelations(t, "self", "viewer"), source{}) + findings := ExtractAllAs[*Finding](validateNames(modelWithRelations(t, "self", "viewer"), source{})) require.Len(t, findings, 1) assert.Nil(t, findings[0].Line) diff --git a/pkg/go/validation/schema_validation.go b/pkg/go/validation/schema_validation.go index 52d7a84b..1a1730cb 100644 --- a/pkg/go/validation/schema_validation.go +++ b/pkg/go/validation/schema_validation.go @@ -7,19 +7,19 @@ import ( // 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) Findings { +func validateSchemaVersion(model *openfgav1.AuthorizationModel, src source) error { version := model.GetSchemaVersion() switch version { case "": - return Findings{schemaVersionRequired().at(src, 0)} + return schemaVersionRequired().at(src, 0) case "1.1", "1.2": return nil case "1.0": // Recognized but retired. - return Findings{schemaVersionUnsupported(version).at(src, src.schemaLine(version))} + return schemaVersionUnsupported(version).at(src, src.schemaLine(version)) default: // Never a valid schema version. - return Findings{invalidSchemaVersion(version).at(src, src.schemaLine(version))} + return invalidSchemaVersion(version).at(src, src.schemaLine(version)) } } diff --git a/pkg/go/validation/schema_validation_test.go b/pkg/go/validation/schema_validation_test.go index 0ea7a6bb..a538f174 100644 --- a/pkg/go/validation/schema_validation_test.go +++ b/pkg/go/validation/schema_validation_test.go @@ -18,14 +18,14 @@ func TestValidateSchemaVersion(t *testing.T) { t.Run("supported versions yield nothing", func(t *testing.T) { t.Parallel() - assert.Empty(t, validateSchemaVersion(model("1.1"), source{})) - assert.Empty(t, validateSchemaVersion(model("1.2"), source{})) + assert.Empty(t, ExtractAllAs[*Finding](validateSchemaVersion(model("1.1"), source{}))) + assert.Empty(t, ExtractAllAs[*Finding](validateSchemaVersion(model("1.2"), source{}))) }) t.Run("missing version is required at line zero", func(t *testing.T) { t.Parallel() - findings := validateSchemaVersion(model(""), newSource("model\ntype user")) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model(""), newSource("model\ntype user"))) require.Len(t, findings, 1) assert.Equal(t, "schema version required", findings[0].Message) @@ -36,7 +36,7 @@ func TestValidateSchemaVersion(t *testing.T) { t.Run("1.0 is recognized but retired", func(t *testing.T) { t.Parallel() - findings := validateSchemaVersion(model("1.0"), newSource("model\n schema 1.0\ntype user")) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model("1.0"), newSource("model\n schema 1.0\ntype user"))) require.Len(t, findings, 1) assert.Equal(t, "schema version no longer supported", findings[0].Message) @@ -48,7 +48,7 @@ func TestValidateSchemaVersion(t *testing.T) { t.Run("anything else was never valid", func(t *testing.T) { t.Parallel() - findings := validateSchemaVersion(model("1.3"), newSource("model\n schema 1.3\ntype user")) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model("1.3"), newSource("model\n schema 1.3\ntype user"))) require.Len(t, findings, 1) assert.Equal(t, "invalid schema 1.3", findings[0].Message) @@ -59,7 +59,7 @@ func TestValidateSchemaVersion(t *testing.T) { t.Run("no source text means no position", func(t *testing.T) { t.Parallel() - findings := validateSchemaVersion(model("1.3"), source{}) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model("1.3"), source{})) require.Len(t, findings, 1) assert.Nil(t, findings[0].Line) diff --git a/pkg/go/validation/semantic_validation.go b/pkg/go/validation/semantic_validation.go index 061f8d33..0708ba01 100644 --- a/pkg/go/validation/semantic_validation.go +++ b/pkg/go/validation/semantic_validation.go @@ -10,8 +10,8 @@ import ( // 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) Findings { - var fs Findings +func validateRelationReferences(idx *index, src source) error { + var fs []*Finding for _, typeDef := range idx.model.GetTypeDefinitions() { typeName := typeDef.GetType() @@ -45,19 +45,19 @@ func validateRelationReferences(idx *index, src source) Findings { } } - return fs + return joinFindings(fs...) } // 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) Findings { + relationName string, relationMetadata *openfgav1.RelationMetadata, typeLine int) []*Finding { if relationMetadata == nil { return nil } - var fs Findings + var fs []*Finding typeName := typeDef.GetType() file := relationMetadata.GetSourceInfo().GetFile() @@ -94,12 +94,12 @@ func validateTypeRestrictions(idx *index, src source, typeDef *openfgav1.TypeDef // validateTupleToUsersetReferences. Union, intersection and difference are // walked into. func validateUsersetReferences(idx *index, src source, typeDef *openfgav1.TypeDefinition, - relationName string, userset *openfgav1.Userset, typeLine int) Findings { + relationName string, userset *openfgav1.Userset, typeLine int) []*Finding { if userset == nil { return nil } - var fs Findings + var fs []*Finding typeName := typeDef.GetType() file, module := typeMeta(typeDef) @@ -144,7 +144,7 @@ func validateUsersetReferences(idx *index, src source, typeDef *openfgav1.TypeDe // - 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) Findings { + relationName string, ttu *openfgav1.TupleToUserset, typeLine int) []*Finding { fromRelation := ttu.GetTupleset().GetRelation() targetRelation := ttu.GetComputedUserset().GetRelation() @@ -159,21 +159,21 @@ func validateTupleToUsersetReferences(idx *index, src source, typeDef *openfgav1 // 1. The tupleset relation must exist on the current type. if !idx.relationDefined(typeName, fromRelation) { - return Findings{invalidTypeRelation(symbol, typeName, relationName, fromRelation, typeName). + return []*Finding{invalidTypeRelation(symbol, typeName, relationName, fromRelation, typeName). at(src, line).in(file, module)} } // 2. The tupleset relation must be a single direct assignment. fromTypes, isDirect := idx.directlyAssignableTypes(typeName, fromRelation) if !isDirect || len(fromTypes) == 0 { - return Findings{tupleUsersetRequiresDirect(fromRelation, typeName, relationName). + return []*Finding{tupleUsersetRequiresDirect(fromRelation, typeName, relationName). atFromClause(src, line).in(file, module)} } // 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 Findings + var fs []*Finding notValid := make([]*openfgav1.RelationReference, 0, len(fromTypes)) diff --git a/pkg/go/validation/source.go b/pkg/go/validation/source.go index 3e4af001..827b9340 100644 --- a/pkg/go/validation/source.go +++ b/pkg/go/validation/source.go @@ -155,7 +155,7 @@ func (s source) schemaLine(schemaVersion string) int { // 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 `fs.add(invalidType(name).at(src, line))`. +// 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 diff --git a/pkg/go/validation/validate.go b/pkg/go/validation/validate.go index 1e2a4054..c8bc5d48 100644 --- a/pkg/go/validation/validate.go +++ b/pkg/go/validation/validate.go @@ -1,6 +1,8 @@ package validation import ( + "errors" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -8,15 +10,14 @@ import ( // 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 is a Findings holding -// every finding in the order raised, which errors.As recovers: +// It returns nil for a valid model. Otherwise the error joins every finding in +// the order raised, which ExtractAllAs recovers: // -// var findings validation.Findings -// if errors.As(err, &findings) { -// for _, f := range findings { ... } +// for _, finding := range validation.ExtractAllAs[*validation.Finding](err) { +// ... // } func ValidateDSL(model *openfgav1.AuthorizationModel, dsl string) error { - return validate(model, newSource(dsl)).Err() + return validate(model, newSource(dsl)) } // ValidateJSON runs every validation over a model that reached the caller as @@ -24,7 +25,7 @@ func ValidateDSL(model *openfgav1.AuthorizationModel, dsl string) error { // 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{}).Err() + return validate(model, source{}) } // validate runs the validation phases in the reference implementation's order. @@ -36,30 +37,37 @@ func ValidateJSON(model *openfgav1.AuthorizationModel) error { // 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) Findings { +func validate(model *openfgav1.AuthorizationModel, src source) error { if model == nil { return nil } idx := newIndex(model) - fs := validateSchemaVersion(model, src) - fs = append(fs, validateNames(model, src)...) - fs = append(fs, validateRelationReferences(idx, src)...) + 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(fs) == 0 { - fs = append(fs, validateDuplicates(model, src)...) + if len(errs) == 0 { + add(validateDuplicates(model, src)) } - if len(fs) == 0 { - fs = append(fs, validateEntryPoints(idx, src)...) - fs = append(fs, validateTupleToUsersets(idx, src)...) - fs = append(fs, validateComplexOperations(idx, src)...) - fs = append(fs, validateWildcards(idx, src)...) + if len(errs) == 0 { + add(validateEntryPoints(idx, src)) + add(validateTupleToUsersets(idx, src)) + add(validateComplexOperations(idx, src)) + add(validateWildcards(idx, src)) } - fs = append(fs, validateMultiFile(model)...) - fs = append(fs, validateConditions(model, src)...) + add(validateMultiFile(model)) + add(validateConditions(model, src)) - return fs + return errors.Join(errs...) } diff --git a/pkg/go/validation/validate_test.go b/pkg/go/validation/validate_test.go index 83ece080..b53a5440 100644 --- a/pkg/go/validation/validate_test.go +++ b/pkg/go/validation/validate_test.go @@ -64,7 +64,7 @@ type document require.NoError(t, ValidateJSON(nil)) }) - t.Run("findings are recovered with errors.As", func(t *testing.T) { + t.Run("findings are recovered with ExtractAllAs", func(t *testing.T) { t.Parallel() dsl := `model @@ -77,8 +77,7 @@ type document err := ValidateDSL(mustParse(t, dsl), dsl) require.Error(t, err) - var findings Findings - require.ErrorAs(t, err, &findings) + findings := ExtractAllAs[*Finding](err) require.Len(t, findings, 1) finding := findings[0] @@ -117,9 +116,9 @@ type document define viewer: editor` err := ValidateJSON(mustParse(t, dsl)) + require.Error(t, err) - var findings Findings - require.ErrorAs(t, err, &findings) + 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) @@ -153,7 +152,7 @@ func TestValidateCascadeGate(t *testing.T) { TypeDefinitions: []*openfgav1.TypeDefinition{typeDef(), typeDef()}, } - findings := validate(model, source{}) + findings := ExtractAllAs[*Finding](validate(model, source{})) require.Len(t, findings, 1) assert.Equal(t, DuplicatedError, findings[0].Metadata.Kind) @@ -170,8 +169,10 @@ type document define viewer: editor define editor: viewer` - var findings Findings - require.ErrorAs(t, ValidateDSL(mustParse(t, dsl), dsl), &findings) + err := ValidateDSL(mustParse(t, dsl), dsl) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) require.Len(t, findings, 2) for _, finding := range findings { @@ -195,8 +196,10 @@ condition marked(x: int) { x > 0 }` - var findings Findings - require.ErrorAs(t, ValidateDSL(mustParse(t, dsl), dsl), &findings) + 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) @@ -215,7 +218,7 @@ func TestValidateFileAndModule(t *testing.T) { SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, } - findings := validate(model, source{}) + findings := ExtractAllAs[*Finding](validate(model, source{})) require.Len(t, findings, 1) assert.Equal(t, "core.fga", findings[0].File) @@ -241,7 +244,7 @@ func TestValidateMultiFile(t *testing.T) { }, } - findings := validateMultiFile(model) + findings := ExtractAllAs[*Finding](validateMultiFile(model)) require.Len(t, findings, 1) assert.Equal(t, MultipleModulesInFile, findings[0].Metadata.Kind) diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go index 6a4bbadc..7b0320e5 100644 --- a/pkg/go/validation/wildcard_validation.go +++ b/pkg/go/validation/wildcard_validation.go @@ -10,8 +10,8 @@ import ( // 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) Findings { - var fs Findings +func validateWildcards(idx *index, src source) error { + var fs []*Finding for _, typeDef := range idx.model.GetTypeDefinitions() { if typeDef.GetMetadata() == nil { @@ -55,15 +55,15 @@ func validateWildcards(idx *index, src source) Findings { } } - return fs + return joinFindings(fs...) } // 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) Findings { - var fs Findings +func validateTupleToUsersets(idx *index, src source) error { + var fs []*Finding for _, typeDef := range idx.model.GetTypeDefinitions() { relations := typeDef.GetRelations() @@ -72,20 +72,20 @@ func validateTupleToUsersets(idx *index, src source) Findings { } } - return fs + return joinFindings(fs...) } // 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) Findings { +func tuplesetsIn(idx *index, src source, typeName, relationName string, userset *openfgav1.Userset) []*Finding { if userset == nil { return nil } - var fs Findings + var fs []*Finding if ttu := userset.GetTupleToUserset(); ttu != nil { - fs = fs.add(tuplesetNotAssignable(idx, src, typeName, relationName, ttu)) + fs = append(fs, tuplesetNotAssignable(idx, src, typeName, relationName, ttu)) } if union := userset.GetUnion(); union != nil { diff --git a/pkg/go/validation/yaml_integration_test.go b/pkg/go/validation/yaml_integration_test.go index 680ec97d..c712d0ab 100644 --- a/pkg/go/validation/yaml_integration_test.go +++ b/pkg/go/validation/yaml_integration_test.go @@ -83,13 +83,13 @@ func TestCompareWithCorpus(t *testing.T) { tests := []struct { name string expected []YAMLExpectedError - findings Findings + findings []*Finding problems int }{ { name: "match", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(nil)}, + findings: []*Finding{finding(nil)}, }, { name: "no errors expected and none found", @@ -102,7 +102,7 @@ func TestCompareWithCorpus(t *testing.T) { // unmatched and the finding was not expected. name: "message is longer than the corpus states", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Message += " Did you mean `view`?" })}, problems: 2, @@ -110,7 +110,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong line", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Line = &Range{Start: 5, End: 5} })}, problems: 1, @@ -118,7 +118,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "line end differs", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Line = &Range{Start: 4, End: 6} })}, problems: 1, @@ -126,7 +126,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "no position at all", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Line, f.Column = nil, nil })}, problems: 1, @@ -134,7 +134,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong column", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Column = &Range{Start: 12, End: 17} })}, problems: 1, @@ -142,7 +142,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong symbol", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Metadata.Symbol = "editor" })}, problems: 1, @@ -150,7 +150,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "wrong error type", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Metadata.Kind = UndefinedRelation })}, problems: 1, @@ -158,13 +158,13 @@ func TestCompareWithCorpus(t *testing.T) { { name: "one finding does not satisfy two expectations", expected: []YAMLExpectedError{expected, expected}, - findings: Findings{finding(nil)}, + findings: []*Finding{finding(nil)}, problems: 1, }, { name: "finding the corpus does not expect", expected: []YAMLExpectedError{expected}, - findings: Findings{finding(nil), finding(func(f *Finding) { + findings: []*Finding{finding(nil), finding(func(f *Finding) { f.Message = "the relation `editor` does not exist." })}, problems: 1, @@ -172,7 +172,7 @@ func TestCompareWithCorpus(t *testing.T) { { name: "position the corpus leaves out is not compared", expected: []YAMLExpectedError{{Message: expected.Message}}, - findings: Findings{finding(func(f *Finding) { + findings: []*Finding{finding(func(f *Finding) { f.Line, f.Column = nil, nil })}, }, diff --git a/pkg/go/validation/yaml_test_integration_test.go b/pkg/go/validation/yaml_test_integration_test.go index 578120d1..7964b367 100644 --- a/pkg/go/validation/yaml_test_integration_test.go +++ b/pkg/go/validation/yaml_test_integration_test.go @@ -1,7 +1,6 @@ package validation import ( - "errors" "fmt" "os" "path/filepath" @@ -21,11 +20,8 @@ const ( ) // findingsOf recovers the findings behind a validation error; nil in, none out. -func findingsOf(err error) Findings { - var findings Findings - errors.As(err, &findings) - - return findings +func findingsOf(err error) []*Finding { + return ExtractAllAs[*Finding](err) } // YAMLTestCase is one case from the shared validation corpus under tests/data. @@ -133,7 +129,7 @@ func (runner *YAMLTestRunner) RunTestCase(testCase YAMLTestCase) *YAMLTestResult // 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 Findings) *YAMLTestResult { +func compareWithCorpus(expectedErrors []YAMLExpectedError, findings []*Finding) *YAMLTestResult { result := &YAMLTestResult{} claimed := make([]bool, len(findings)) matched := make([]bool, len(expectedErrors))