diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0babafea..9e3e4f14 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: @@ -23,9 +23,9 @@ jobs: make plugin_ci make test - name: Lint programs - uses: golangci/golangci-lint-action@v5 + uses: golangci/golangci-lint-action@v6 with: version: v1.58 - skip-pkg-cache: true - skip-build-cache: true - skip-go-installation: true + skip-cache: true + skip-save-cache: true + install-mode: binary diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index a88b051e..a9afe0f1 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -8,7 +8,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: @@ -16,9 +16,9 @@ jobs: - name: Run tests run: make test - name: Lint programs - uses: golangci/golangci-lint-action@v5 + uses: golangci/golangci-lint-action@v6 with: version: v1.58 - skip-pkg-cache: true - skip-build-cache: true - skip-go-installation: true + skip-cache: true + skip-save-cache: true + install-mode: binary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7687a9c..86b99382 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: @@ -18,12 +18,12 @@ jobs: - name: Run tests run: make test - name: Lint programs - uses: golangci/golangci-lint-action@v5 + uses: golangci/golangci-lint-action@v6 with: version: v1.58 - skip-pkg-cache: true - skip-build-cache: true - skip-go-installation: true + skip-cache: true + skip-save-cache: true + install-mode: binary - name: Set version id: version run: | diff --git a/cmd/falco/runner.go b/cmd/falco/runner.go index 9e8153e8..6228c7e4 100644 --- a/cmd/falco/runner.go +++ b/cmd/falco/runner.go @@ -417,6 +417,10 @@ func (r *Runner) Simulate(rslv resolver.Resolver) error { if r.config.OverrideBackends != nil { options = append(options, icontext.WithOverrideBackends(r.config.OverrideBackends)) } + // If simulator configuration has edge dictionaries, inject them + if sc.OverrideEdgeDictionaries != nil { + options = append(options, icontext.WithInjectEdgeDictionaries(sc.OverrideEdgeDictionaries)) + } i := interpreter.New(options...) diff --git a/cmd/falco/runner_test.go b/cmd/falco/runner_test.go index 5cb35ac8..4b268c81 100644 --- a/cmd/falco/runner_test.go +++ b/cmd/falco/runner_test.go @@ -338,6 +338,12 @@ func TestTester(t *testing.T) { filter: "*group.test.vcl", passes: 3, }, + { + name: "mockging test", + main: "../../examples/testing/mock_subroutine.vcl", + filter: "*mock_subroutine.test.vcl", + passes: 6, + }, } for _, tt := range tests { diff --git a/config/config.go b/config/config.go index 0b337ffc..f5260092 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,8 @@ type OverrideBackend struct { Unhealthy bool `yaml:"unhealthy" default:"false"` } +type EdgeDictionary map[string]string + // Linter configuration type LinterConfig struct { VerboseLevel string `yaml:"verbose"` @@ -47,6 +49,9 @@ type SimulatorConfig struct { KeyFile string `cli:"key" yaml:"key_file"` CertFile string `cli:"cert" yaml:"cert_file"` + // Inject Edge Dictionary items + OverrideEdgeDictionaries map[string]EdgeDictionary `yaml:"edge_dictionary"` + // Override Request configuration OverrideRequest *RequestConfig } diff --git a/docs/configuration.md b/docs/configuration.md index 40de0f6b..5ac02036 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,6 +29,10 @@ simulator: max_acls: 100 key_file: /path/to/key_file.pem cert_file: /path/to/cert_file.pem + edge_dictionary: + dict_name: + key1: value1 + key2: value2 ## Testing configuration testing: @@ -55,6 +59,10 @@ All configurations of configuration files and CLI arguments are described follow | max_acls | Integer | 1000 | --max_acls | Override Fastly's acl amount limitation | | simulator | Object | null | - | Simulator configuration object | | simulator.port | Integer | 3124 | -p, --port | Simulator server listen port | +| simulator.key_file | String | - | --key | TLS server key file path | +| simulator.cert_file | String | - | --cert | TLS server cert file path | +| simulator.edge_dictionary | Object | null | - | Local edge dictionary item definitions | +| simulator.edge_dictionary.[name] | Object | - | - | Local edge dictionary name | | testing | Object | null | - | Testing configuration object | | testing.timeout | Integer | 10 | -t, --timeout | Set timeout to stop testing | | linter | Object | null | - | Override linter rules | diff --git a/docs/simulator.md b/docs/simulator.md index 669587c6..56a93a3a 100644 --- a/docs/simulator.md +++ b/docs/simulator.md @@ -79,6 +79,13 @@ falco simulate /path/to/your/default.vcl --key /path/to/localhost-key.pem --cert Then falco serve with https://localhost:3124. +## Override Edge Dictionary Items + +Edge Dictionary values are managed in Fastly cloud but often we have some logics that relates to its value (e.g flag true/false), and write-only dictionary items could access via remote API. +To simulate its behavior with specific value, falco supports overriding edge dictionary item locally from configuration. + +See `simulator.edge_dictionary` field in [configuration.md](./configuration.md). + ## Debug mode `falco` also includes TUI debugger so that you can debug VCL with step execution. diff --git a/docs/testing.md b/docs/testing.md index d37fbca0..b0644425 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -194,6 +194,9 @@ We describe them following table and examples: | testing.inspect | FUNCTION | Inspect predefined variables for any scopes | | testing.table_set | FUNCTION | Inject value for key to main VCL table | | testing.table_merge | FUNCTION | Merge values from testing VCL table to main VCL table | +| testing.mock | FUNCTION | Mock the subroutine with specified subroutine in the testing VCL | +| testing.resotre_mock | FUNCTION | Restore specific mocked subroutine | +| testing.restore_all_mocks | FUNCTION | Restore all mocked subroutines | | assert | FUNCTION | Assert provided expression should be true | | assert.true | FUNCTION | Assert actual value should be true | | assert.false | FUNCTION | Assert actual value should be false | @@ -353,6 +356,125 @@ sub test_vcl { ---- +### testing.mock(STRING from, STRING to) + +Mock the subroutine with testing subroutine. + +> [!NOTE] +> You cannot mock Fastly reserved (lifecycle) subroutine that starts with `vcl_` like `vcl_recv`, `vcl_fetch`, etc. +> But you can mock the functional subroutine that returns some value. + +```vcl + +sub mock_add_header { + set req.http.Mocked = "1"; +} + +// @scope: recv +sub test_vcl { + // Mock the subroutine + testing.mock("add_header", "mock_add_header"); + + // vcl_recv has a dependency that calls "add_header" subroutine inside. + testing.call_subroutine("vcl_recv"); + + // Assert mocked subroutine result + assert.equal(req.http.Mocked, "1"); +} +``` + +---- + +### testing.restore_mock(STRING from) + +Restore mocked subroutine to the original. +Normally This function is used inside `describe` grouped testing hooks. + +```vcl + +sub mock_add_header { + set req.http.Mocked = "1"; +} + +describe add_header_mock { + + sub before_recv { + // Mock subroutine + testing.mock("add_header", "mock_add_header"); + } + + sub after_recv { + // Restore mock + testing.restore_mock("add_header"); + } + + // @scope: recv + sub test_vcl { + // Mock the subroutine + testing.mock("add_header", "mock_add_header"); + + // vcl_recv has a dependency that calls "add_header" subroutine inside. + testing.call_subroutine("vcl_recv"); + + // Assert mocked subroutine result + assert.equal(req.http.Mocked, "1"); + } + + // @scope: fetch + sub test_fetch { + // This subroutine no longer uses mocked subroutine + ... + } +} +``` + +---- + +### testing.restore_all_mocks() + +Restore all mocked subroutines. +Normally This function is used inside `describe` grouped testing hooks. + +```vcl + +sub mock_add_header { + set req.http.Mocked = "1"; +} + +describe add_header_mock { + + sub before_recv { + // Mock subroutine + testing.mock("add_header", "mock_add_header"); + } + + sub after_recv { + // Restore all mocks + testing.restore_all_mocks(); + } + + // @scope: recv + sub test_vcl { + // Mock the subroutine + testing.mock("add_header", "mock_add_header"); + + // vcl_recv has a dependency that calls "add_header" subroutine inside. + testing.call_subroutine("vcl_recv"); + + // Assert mocked subroutine result + assert.equal(req.http.Mocked, "1"); + } + + // @scope: fetch + sub test_fetch { + // This subroutine no longer uses mocked subroutine + ... + } +} +``` + +---- + ### assert(ANY expr [, STRING message]) Assert provided expression should be truthy. diff --git a/examples/simulator/simulator.vcl b/examples/simulator/simulator.vcl index 05f5edab..ebb041a6 100644 --- a/examples/simulator/simulator.vcl +++ b/examples/simulator/simulator.vcl @@ -28,12 +28,16 @@ backend example_com { } } +table injectable_dict STRING { +} + sub vcl_recv { #Fastly recv // @debugger set req.backend = example_com; set req.http.Foo = {" foo bar baz "}; + set req.http.Item = table.lookup(injectable_dict, "virtual"); call custom_logger; return (pass); } diff --git a/examples/testing/mock_subroutine.test.vcl b/examples/testing/mock_subroutine.test.vcl new file mode 100644 index 00000000..e110c24f --- /dev/null +++ b/examples/testing/mock_subroutine.test.vcl @@ -0,0 +1,37 @@ +sub mocked { + set req.http.Mocked = "1"; +} + +sub mocked_func STRING { + return "Mocked"; +} + +describe group { + + before_recv { + testing.mock("original", "mocked"); + testing.mock("original_func", "mocked_func"); + } + + after_recv { + testing.restore_all_mocks(); + unset req.http.Mocked; + } + + // @scope: recv + sub test_recv { + testing.call_subroutine("vcl_recv"); + assert.equal(req.http.Mocked, "1"); + assert.is_notset(req.http.Original); + assert.equal(req.http.FuncValue, "Mocked"); + } + + // @scope: fetch + sub test_fetch { + testing.call_subroutine("vcl_fetch"); + assert.equal(req.http.Original, "1"); + assert.is_notset(req.http.Mocked); + assert.equal(req.http.FuncValue, "Original"); + } + +} diff --git a/examples/testing/mock_subroutine.vcl b/examples/testing/mock_subroutine.vcl new file mode 100644 index 00000000..ace3bc3e --- /dev/null +++ b/examples/testing/mock_subroutine.vcl @@ -0,0 +1,21 @@ +// @scope: recv,fetch +sub original { + set req.http.Original = "1"; +} + +// @scope: recv,fetch +sub original_func STRING { + return "Original"; +} + +sub vcl_recv { + #FASTLY RECV + call original; + set req.http.FuncValue = original_func(); +} + +sub vcl_fetch { + #FASTLY FETCH + call original; + set req.http.FuncValue = original_func(); +} diff --git a/interpreter/context/context.go b/interpreter/context/context.go index 0f0ccbdf..44d519c9 100644 --- a/interpreter/context/context.go +++ b/interpreter/context/context.go @@ -56,10 +56,15 @@ type Context struct { SubroutineFunctions map[string]*ast.SubroutineDeclaration OriginalHost string - OverrideMaxBackends int - OverrideMaxAcls int - OverrideRequest *config.RequestConfig - OverrideBackends map[string]*config.OverrideBackend + OverrideMaxBackends int + OverrideMaxAcls int + OverrideRequest *config.RequestConfig + OverrideBackends map[string]*config.OverrideBackend + InjectEdgeDictionaries map[string]config.EdgeDictionary + + // Mocking subroutines map + MockedSubroutines map[string]*ast.SubroutineDeclaration + MockedFunctioncalSubroutines map[string]*ast.SubroutineDeclaration Request *http.Request BackendRequest *http.Request @@ -162,15 +167,19 @@ type Context struct { func New(options ...Option) *Context { ctx := &Context{ - Acls: make(map[string]*value.Acl), - Backends: make(map[string]*value.Backend), - Tables: make(map[string]*ast.TableDeclaration), - Subroutines: make(map[string]*ast.SubroutineDeclaration), - Penaltyboxes: make(map[string]*ast.PenaltyboxDeclaration), - Ratecounters: make(map[string]*ast.RatecounterDeclaration), - Gotos: make(map[string]*ast.GotoStatement), - SubroutineFunctions: make(map[string]*ast.SubroutineDeclaration), - OverrideBackends: make(map[string]*config.OverrideBackend), + Acls: make(map[string]*value.Acl), + Backends: make(map[string]*value.Backend), + Tables: make(map[string]*ast.TableDeclaration), + Subroutines: make(map[string]*ast.SubroutineDeclaration), + Penaltyboxes: make(map[string]*ast.PenaltyboxDeclaration), + Ratecounters: make(map[string]*ast.RatecounterDeclaration), + Gotos: make(map[string]*ast.GotoStatement), + SubroutineFunctions: make(map[string]*ast.SubroutineDeclaration), + OverrideBackends: make(map[string]*config.OverrideBackend), + InjectEdgeDictionaries: make(map[string]config.EdgeDictionary), + + MockedSubroutines: make(map[string]*ast.SubroutineDeclaration), + MockedFunctioncalSubroutines: make(map[string]*ast.SubroutineDeclaration), CacheHitItem: nil, RequestStartTime: time.Now(), diff --git a/interpreter/context/option.go b/interpreter/context/option.go index febc4ad1..6375f448 100644 --- a/interpreter/context/option.go +++ b/interpreter/context/option.go @@ -49,3 +49,9 @@ func WithOverrideHost(host string) Option { c.OriginalHost = host } } + +func WithInjectEdgeDictionaries(ed map[string]config.EdgeDictionary) Option { + return func(c *Context) { + c.InjectEdgeDictionaries = ed + } +} diff --git a/interpreter/edge_dictionary.go b/interpreter/edge_dictionary.go new file mode 100644 index 00000000..d54e0463 --- /dev/null +++ b/interpreter/edge_dictionary.go @@ -0,0 +1,70 @@ +package interpreter + +import ( + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/config" + "github.com/ysugimoto/falco/token" +) + +// Inject edge dictionary item from configuration. +// Edge dictionary value is managed in Fastly could so typically items are readonly. +// However we need to set some items in local simulator, particularly write-only edge dictionary +// So the interpreter can inject virtual value from falco coniguration. +func (i *Interpreter) InjectEdgeDictionaryItem(table *ast.TableDeclaration, dict config.EdgeDictionary) error { + for key, val := range dict { + idx := -1 + // Find existing key index + for i, prop := range table.Properties { + if prop.Key.Value == key { + idx = i + break + } + } + + inject := createInjectTableProperty(key, val) + if idx == -1 { + // If key not found, simply append inject value + table.Properties = append(table.Properties, inject) + } else { + // Otherwise, replace its value + table.Properties[idx] = inject + } + } + return nil +} + +// Create virtual ast.TableProperty node with injected value +func createInjectTableProperty(key, value string) *ast.TableProperty { + return &ast.TableProperty{ + Meta: ast.New(token.Token{ + Type: token.STRING, + Literal: value, + Line: 0, + Position: 0, + Offset: 0, + File: "EdgeDictionary.Injected", + }, 0), + Key: &ast.String{ + Meta: ast.New(token.Token{ + Type: token.STRING, + Literal: key, + Line: 0, + Position: 0, + Offset: 0, + File: "EdgeDictionary.Injected", + }, 0), + Value: key, + }, + Value: &ast.String{ + Meta: ast.New(token.Token{ + Type: token.STRING, + Literal: value, + Line: 0, + Position: 0, + Offset: 0, + File: "EdgeDictionary.Injected", + }, 0), + Value: value, + }, + } +} diff --git a/interpreter/expression.go b/interpreter/expression.go index a8b4507c..4ab9cb74 100644 --- a/interpreter/expression.go +++ b/interpreter/expression.go @@ -234,6 +234,10 @@ func (i *Interpreter) ProcessFunctionCallExpression(exp *ast.FunctionCallExpress exp.Function.Value, ) } + // If mocked functional subroutine found, use it + if mocked, ok := i.ctx.MockedFunctioncalSubroutines[exp.Function.Value]; ok { + sub = mocked + } if _, ok := types.ValueTypeMap[sub.ReturnType.Value]; !ok { return value.Null, exception.Runtime( &sub.GetMeta().Token, diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index cd481e11..2170582b 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -181,6 +181,17 @@ func (i *Interpreter) ProcessDeclarations(statements []ast.Statement) error { return exception.Runtime(&t.Token, "Table %s is duplicated", t.Name.Value) } i.ctx.Tables[t.Name.Value] = t + + // Set items if injected edge dictionaries exists + if inject, ok := i.ctx.InjectEdgeDictionaries[t.Name.Value]; ok { + // Edge Dictionary value type must be STRING + if t.ValueType.Value != "STRING" { + return exception.System("EdgeDictionary injection error: %s value type is not STRING", t.Name.Value) + } + if err := i.InjectEdgeDictionaryItem(t, inject); err != nil { + return errors.WithStack(err) + } + } case *ast.SubroutineDeclaration: i.Debugger.Run(stmt) if t.ReturnType != nil { diff --git a/interpreter/statement.go b/interpreter/statement.go index 5cdcd514..bf12f6b5 100644 --- a/interpreter/statement.go +++ b/interpreter/statement.go @@ -282,12 +282,22 @@ func (i *Interpreter) ProcessCallStatement(stmt *ast.CallStatement, ds DebugStat var state State var err error name := stmt.Subroutine.Value + if sub, ok := i.ctx.SubroutineFunctions[name]; ok { + // If mocked functional subroutine exists, use it + if mocked, ok := i.ctx.MockedFunctioncalSubroutines[name]; ok { + sub = mocked + } + _, state, err = i.ProcessFunctionSubroutine(sub, ds) if err != nil { return NONE, errors.WithStack(err) } } else if sub, ok = i.ctx.Subroutines[name]; ok { + // If mocked subroutine exists, use it + if mocked, ok := i.ctx.MockedSubroutines[name]; ok { + sub = mocked + } state, err = i.ProcessSubroutine(sub, ds) if err != nil { return NONE, errors.WithStack(err) diff --git a/linter/rules.go b/linter/rules.go index 9907f441..cb00bbc8 100644 --- a/linter/rules.go +++ b/linter/rules.go @@ -32,7 +32,7 @@ const ( SUBROUTINE_BOILERPLATE_MACRO = "subroutine/boilerplate-macro" SUBROUTINE_DUPLICATED = "subroutine/duplicated" SUBROUTINE_INVALID_RETURN_TYPE = "subroutine/invalid-return-type" - UNRECOGNIZE_CALL_SCOPE = "sburoutine/unrecognize-call-scope" + UNRECOGNIZE_CALL_SCOPE = "subroutine/unrecognize-call-scope" PENALTYBOX_SYNTAX = "penaltybox/syntax" PENALTYBOX_DUPLICATED = "penaltybox/duplicated" PENALTYBOX_NONEMPTY_BLOCK = "penaltybox/nonempty-block" diff --git a/snippets/template.go b/snippets/template.go index c7fc3cbf..36afa933 100644 --- a/snippets/template.go +++ b/snippets/template.go @@ -1,7 +1,7 @@ package snippets var tableTemplate = ` -table {{ .Name }} { +table {{ .Name }} STRING { {{- range .Items }} "{{ .Key }}": "{{ .Value }}", {{- end }} diff --git a/tester/function/testing_call_subroutine.go b/tester/function/testing_call_subroutine.go index c36457ce..9b7b0166 100644 --- a/tester/function/testing_call_subroutine.go +++ b/tester/function/testing_call_subroutine.go @@ -7,7 +7,7 @@ import ( "github.com/ysugimoto/falco/interpreter/value" ) -const Testing_call_subroutine_Name = "assert" +const Testing_call_subroutine_Name = "testing.call_subroutine" var Testing_call_subroutine_ArgumentTypes = []value.Type{value.StringType} diff --git a/tester/function/testing_functions.go b/tester/function/testing_functions.go index 74914cd2..38460b3d 100644 --- a/tester/function/testing_functions.go +++ b/tester/function/testing_functions.go @@ -17,9 +17,10 @@ type Counter interface { } type Definiions struct { - Tables map[string]*ast.TableDeclaration - Backends map[string]*value.Backend - Acls map[string]*value.Acl + Tables map[string]*ast.TableDeclaration + Backends map[string]*value.Backend + Acls map[string]*value.Acl + Subroutines map[string]*ast.SubroutineDeclaration } type Functions map[string]*ifn.Function @@ -112,6 +113,34 @@ func testingFunctions(i *interpreter.Interpreter, defs *Definiions) Functions { return false }, }, + "testing.mock": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + return Testing_mock(ctx, defs, args...) + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "testing.restore_mock": { + Scope: allScope, + Call: Testing_restore_mock, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, + "testing.restore_all_mocks": { + Scope: allScope, + Call: func(ctx *context.Context, args ...value.Value) (value.Value, error) { + return Testing_restore_all_mocks(ctx) + }, + CanStatementCall: true, + IsIdentArgument: func(i int) bool { + return false + }, + }, } } diff --git a/tester/function/testing_mock.go b/tester/function/testing_mock.go new file mode 100644 index 00000000..ec86fd8b --- /dev/null +++ b/tester/function/testing_mock.go @@ -0,0 +1,99 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Testing_mock_Name = "testing.mock" + +var Testing_mock_ArgumentTypes = []value.Type{value.StringType, value.StringType} + +func Testing_mock_Validate(args []value.Value) error { + if len(args) != 2 { + return errors.ArgumentNotEnough(Testing_mock_Name, 2, args) + } + + for i := range Testing_mock_ArgumentTypes { + if args[i].Type() != Testing_mock_ArgumentTypes[i] { + return errors.TypeMismatch( + Testing_mock_Name, i+1, Testing_mock_ArgumentTypes[i], args[i].Type(), + ) + } + } + return nil +} + +func Testing_mock( + ctx *context.Context, + defs *Definiions, + args ...value.Value, +) (value.Value, error) { + + if err := Testing_mock_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + from := value.Unwrap[*value.String](args[0]).Value + to := value.Unwrap[*value.String](args[1]).Value + + if _, ok := Testing_mock_Fastly_reserved[from]; ok { + return value.Null, errors.NewTestingError("Cannot mock Fastly reserved subroutine %s", from) + } + + // Check subroutine existence + mockFrom, ok := ctx.Subroutines[from] + if !ok { + if mockFrom, ok = ctx.SubroutineFunctions[from]; !ok { + return value.Null, errors.NewTestingError("subroutine %s is not declared in VCL", from) + } + } + + mockTo, ok := defs.Subroutines[to] + if !ok { + return value.Null, errors.NewTestingError("mock subroutine %s is not declared in testing VCL", to) + } + + // Check functional subroutine + if mockFrom.ReturnType != nil { + if mockTo.ReturnType == nil { + return value.Null, errors.NewTestingError( + "%s is functional subroutine but mock target %s is not functional", from, to, + ) + } + + // Return type must match between original and mock target + if mockFrom.ReturnType.Value != mockTo.ReturnType.Value { + return value.Null, errors.NewTestingError( + "mocking subroutine return type mismatch, original is %s, mock target is %s", + mockFrom.ReturnType.Value, + mockTo.ReturnType.Value, + ) + } + + ctx.MockedFunctioncalSubroutines[from] = mockTo + return value.Null, nil + } + + if mockTo.ReturnType != nil { + return value.Null, errors.NewTestingError( + "%s is subroutine but mock target %s is functional subroutine", from, to, + ) + } + + ctx.MockedSubroutines[from] = mockTo + return value.Null, nil +} + +var Testing_mock_Fastly_reserved = map[string]struct{}{ + "vcl_recv": {}, + "vcl_hash": {}, + "vcl_hit": {}, + "vcl_miss": {}, + "vcl_pass": {}, + "vcl_fetch": {}, + "vcl_error": {}, + "vcl_deliver": {}, + "vcl_log": {}, +} diff --git a/tester/function/testing_mock_test.go b/tester/function/testing_mock_test.go new file mode 100644 index 00000000..839a2b3c --- /dev/null +++ b/tester/function/testing_mock_test.go @@ -0,0 +1,278 @@ +package function + +import ( + "testing" + + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/value" + "github.com/ysugimoto/falco/lexer" + "github.com/ysugimoto/falco/parser" +) + +func Test_mock(t *testing.T) { + tests := []struct { + name string + input string + mock string + isError bool + }{ + { + name: "mock subroutine", + input: `sub original { + set req.http.Original = "1"; + }`, + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + }, + { + name: "name mismatch", + input: `sub undefined { + set req.http.Original = "1"; + }`, + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + isError: true, + }, + { + name: "cannot mock functional subroutine", + input: `sub undefined STRING { + set req.http.Original = "1"; + return "FOO"; + }`, + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + isError: true, + }, + { + name: "cannot mock target functional subroutine", + input: `sub original { + set req.http.Original = "1"; + }`, + mock: `sub mocked STRING { + set req.http.Mocked = "1"; + return "FOO"; + }`, + isError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + from, err := parser.New(lexer.NewFromString(tt.input)).ParseVCL() + if err != nil { + t.Errorf("Unexpected input parse error: %s", err) + return + } + fromSubroutine := from.Statements[0].(*ast.SubroutineDeclaration) + mock, err := parser.New(lexer.NewFromString(tt.mock)).ParseVCL() + if err != nil { + t.Errorf("Unexpected mock parse error: %s", err) + return + } + mockSubroutine := mock.Statements[0].(*ast.SubroutineDeclaration) + + c := &context.Context{ + Subroutines: map[string]*ast.SubroutineDeclaration{ + fromSubroutine.Name.Value: fromSubroutine, + }, + MockedSubroutines: map[string]*ast.SubroutineDeclaration{}, + } + defs := &Definiions{ + Subroutines: map[string]*ast.SubroutineDeclaration{ + mockSubroutine.Name.Value: mockSubroutine, + }, + } + _, err = Testing_mock( + c, + defs, + &value.String{Value: "original"}, + &value.String{Value: "mocked"}, + ) + if tt.isError { + if err == nil { + t.Errorf("Expected error but nil") + } + return + } + v, ok := c.MockedSubroutines["original"] + if !ok { + t.Errorf("Expected mocked subroutine exists but not found") + return + } + if v.Name.Value != "mocked" { + t.Errorf("Expected mocked subroutine exists but name is mismatch") + } + }) + } +} + +func Test_mock_functional_subroutine(t *testing.T) { + + tests := []struct { + name string + input string + mock string + isError bool + }{ + { + name: "mock functional subroutine", + input: `sub original STRING { + set req.http.Original = "1"; + return "FOO"; + }`, + mock: `sub mocked STRING { + set req.http.Mocked = "1"; + return "BAR"; + }`, + }, + { + name: "name mismatch", + input: `sub undefined STRING { + set req.http.Original = "1"; + return "FOO"; + }`, + mock: `sub mocked STRING { + set req.http.Mocked = "1"; + return "BAR"; + }`, + isError: true, + }, + { + name: "cannot mock stateful subroutine", + input: `sub undefined STRING { + set req.http.Original = "1"; + return "FOO"; + }`, + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + isError: true, + }, + { + name: "mock type mismatch", + input: `sub original INTEGER { + set req.http.Original = "1"; + return 1; + }`, + mock: `sub mocked STRING { + set req.http.Mocked = "1"; + return "FOO"; + }`, + isError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + from, err := parser.New(lexer.NewFromString(tt.input)).ParseVCL() + if err != nil { + t.Errorf("Unexpected input parse error: %s", err) + return + } + fromSubroutine := from.Statements[0].(*ast.SubroutineDeclaration) + mock, err := parser.New(lexer.NewFromString(tt.mock)).ParseVCL() + if err != nil { + t.Errorf("Unexpected mock parse error: %s", err) + return + } + mockSubroutine := mock.Statements[0].(*ast.SubroutineDeclaration) + + c := &context.Context{ + SubroutineFunctions: map[string]*ast.SubroutineDeclaration{ + fromSubroutine.Name.Value: fromSubroutine, + }, + MockedFunctioncalSubroutines: map[string]*ast.SubroutineDeclaration{}, + } + defs := &Definiions{ + Subroutines: map[string]*ast.SubroutineDeclaration{ + mockSubroutine.Name.Value: mockSubroutine, + }, + } + _, err = Testing_mock( + c, + defs, + &value.String{Value: "original"}, + &value.String{Value: "mocked"}, + ) + if tt.isError { + if err == nil { + t.Errorf("Expected error but nil") + } + return + } + v, ok := c.MockedFunctioncalSubroutines["original"] + if !ok { + t.Errorf("Expected mocked subroutine exists but not found") + return + } + if v.Name.Value != "mocked" { + t.Errorf("Expected mocked subroutine exists but name is mismatch") + } + }) + } +} + +func Test_mock_fastly_reserved_subroutine(t *testing.T) { + + tests := []struct { + name string + input string + mock string + isError bool + }{ + { + name: "cannot mock fastly reserved subroutine", + input: `sub vcl_recv { + set req.http.Original = "1"; + }`, + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + isError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + from, err := parser.New(lexer.NewFromString(tt.input)).ParseVCL() + if err != nil { + t.Errorf("Unexpected input parse error: %s", err) + return + } + fromSubroutine := from.Statements[0].(*ast.SubroutineDeclaration) + mock, err := parser.New(lexer.NewFromString(tt.mock)).ParseVCL() + if err != nil { + t.Errorf("Unexpected mock parse error: %s", err) + return + } + mockSubroutine := mock.Statements[0].(*ast.SubroutineDeclaration) + + c := &context.Context{ + Subroutines: map[string]*ast.SubroutineDeclaration{ + fromSubroutine.Name.Value: fromSubroutine, + }, + } + defs := &Definiions{ + Subroutines: map[string]*ast.SubroutineDeclaration{ + mockSubroutine.Name.Value: mockSubroutine, + }, + } + _, err = Testing_mock( + c, + defs, + &value.String{Value: "vcl_recv"}, + &value.String{Value: "mocked"}, + ) + if tt.isError { + if err == nil { + t.Errorf("Expected error but nil") + } + return + } + }) + } +} diff --git a/tester/function/testing_restore_all_mocks.go b/tester/function/testing_restore_all_mocks.go new file mode 100644 index 00000000..9b5923ee --- /dev/null +++ b/tester/function/testing_restore_all_mocks.go @@ -0,0 +1,33 @@ +package function + +import ( + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Testing_restore_all_mocks_Name = "testing.restore_all_mocks" + +func Testing_restore_all_mocks_Validate(args []value.Value) error { + if len(args) > 0 { + return errors.ArgumentMustEmpty(Testing_restore_all_mocks_Name, args) + } + return nil +} + +func Testing_restore_all_mocks( + ctx *context.Context, + args ...value.Value, +) (value.Value, error) { + + if err := Testing_restore_all_mocks_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + // clear all mocked subroutines + ctx.MockedSubroutines = map[string]*ast.SubroutineDeclaration{} + ctx.MockedFunctioncalSubroutines = map[string]*ast.SubroutineDeclaration{} + + return value.Null, nil +} diff --git a/tester/function/testing_restore_all_mocks_test.go b/tester/function/testing_restore_all_mocks_test.go new file mode 100644 index 00000000..b748d42e --- /dev/null +++ b/tester/function/testing_restore_all_mocks_test.go @@ -0,0 +1,96 @@ +package function + +import ( + "testing" + + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/lexer" + "github.com/ysugimoto/falco/parser" +) + +func Test_restore_all_mocks(t *testing.T) { + + tests := []struct { + name string + mock string + }{ + { + name: "restore mocked subroutine", + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock, err := parser.New(lexer.NewFromString(tt.mock)).ParseVCL() + if err != nil { + t.Errorf("Unexpected mock parse error: %s", err) + return + } + mockSubroutine := mock.Statements[0].(*ast.SubroutineDeclaration) + + c := &context.Context{ + MockedSubroutines: map[string]*ast.SubroutineDeclaration{ + "original": mockSubroutine, + }, + } + _, err = Testing_restore_all_mocks(c) + if err != nil { + t.Errorf("Expected no error but returned: %s", err) + } + if len(c.MockedSubroutines) > 0 { + t.Errorf("mocked subroutines must be empty") + } + if len(c.MockedFunctioncalSubroutines) > 0 { + t.Errorf("mocked functional subroutines must be empty") + } + }) + } +} + +func Test_restore_all_functional_mock(t *testing.T) { + + tests := []struct { + name string + mock string + isError bool + }{ + { + name: "restore mocked functional subroutine", + mock: `sub mocked STRING { + set req.http.Mocked = "1"; + return "BAR"; + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock, err := parser.New(lexer.NewFromString(tt.mock)).ParseVCL() + if err != nil { + t.Errorf("Unexpected mock parse error: %s", err) + return + } + mockSubroutine := mock.Statements[0].(*ast.SubroutineDeclaration) + + c := &context.Context{ + MockedFunctioncalSubroutines: map[string]*ast.SubroutineDeclaration{ + "original": mockSubroutine, + }, + } + _, err = Testing_restore_all_mocks(c) + if err != nil { + t.Errorf("Expected no error but returned: %s", err) + } + if len(c.MockedSubroutines) > 0 { + t.Errorf("mocked subroutines must be empty") + } + if len(c.MockedFunctioncalSubroutines) > 0 { + t.Errorf("mocked functional subroutines must be empty") + } + }) + } +} diff --git a/tester/function/testing_restore_mock.go b/tester/function/testing_restore_mock.go new file mode 100644 index 00000000..a8a9bf1e --- /dev/null +++ b/tester/function/testing_restore_mock.go @@ -0,0 +1,49 @@ +package function + +import ( + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/function/errors" + "github.com/ysugimoto/falco/interpreter/value" +) + +const Testing_restore_mock_Name = "testing.restore_mock" + +func Testing_restore_mock_Validate(args []value.Value) error { + if len(args) == 0 { + return errors.ArgumentAtLeast(Testing_restore_mock_Name, 1) + } + + for i := range args { + if args[i].Type() != value.StringType { + return errors.TypeMismatch( + Testing_restore_mock_Name, i+1, value.StringType, args[i].Type(), + ) + } + } + + return nil +} + +func Testing_restore_mock( + ctx *context.Context, + args ...value.Value, +) (value.Value, error) { + + if err := Testing_restore_mock_Validate(args); err != nil { + return nil, errors.NewTestingError(err.Error()) + } + + for i := range args { + name := value.Unwrap[*value.String](args[i]).Value + if _, ok := ctx.MockedSubroutines[name]; ok { + delete(ctx.MockedSubroutines, name) + continue + } + if _, ok := ctx.MockedFunctioncalSubroutines[name]; ok { + delete(ctx.MockedFunctioncalSubroutines, name) + continue + } + return value.Null, errors.NewTestingError("subroutine %s is not mocked", name) + } + return value.Null, nil +} diff --git a/tester/function/testing_restore_mock_test.go b/tester/function/testing_restore_mock_test.go new file mode 100644 index 00000000..0fff981b --- /dev/null +++ b/tester/function/testing_restore_mock_test.go @@ -0,0 +1,128 @@ +package function + +import ( + "testing" + + "github.com/ysugimoto/falco/ast" + "github.com/ysugimoto/falco/interpreter/context" + "github.com/ysugimoto/falco/interpreter/value" + "github.com/ysugimoto/falco/lexer" + "github.com/ysugimoto/falco/parser" +) + +func Test_restore_mock(t *testing.T) { + tests := []struct { + name string + mockName string + mock string + isError bool + }{ + { + name: "restore mocked subroutine", + mockName: "original", + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + }, + { + name: "error if mocked subroutine not found", + mockName: "undefined", + mock: `sub mocked { + set req.http.Mocked = "1"; + }`, + isError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock, err := parser.New(lexer.NewFromString(tt.mock)).ParseVCL() + if err != nil { + t.Errorf("Unexpected mock parse error: %s", err) + return + } + mockSubroutine := mock.Statements[0].(*ast.SubroutineDeclaration) + + c := &context.Context{ + MockedSubroutines: map[string]*ast.SubroutineDeclaration{ + tt.mockName: mockSubroutine, + }, + } + _, err = Testing_restore_mock( + c, + &value.String{Value: "original"}, + ) + if tt.isError { + if err == nil { + t.Errorf("Expected error but nil") + } + return + } + _, ok := c.MockedSubroutines["original"] + if ok { + t.Errorf("Expected mocked subroutine does not exist but exists") + return + } + }) + } +} + +func Test_restore_functional_mock(t *testing.T) { + + tests := []struct { + name string + mockName string + mock string + isError bool + }{ + { + name: "mock functional subroutine", + mockName: "original", + mock: `sub mocked STRING { + set req.http.Mocked = "1"; + return "BAR"; + }`, + }, + { + name: "error if mocked functional subroutine not found", + mockName: "undefined", + mock: `sub mocked STRING { + set req.http.Mocked = "1"; + return "BAR"; + }`, + isError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock, err := parser.New(lexer.NewFromString(tt.mock)).ParseVCL() + if err != nil { + t.Errorf("Unexpected mock parse error: %s", err) + return + } + mockSubroutine := mock.Statements[0].(*ast.SubroutineDeclaration) + + c := &context.Context{ + MockedFunctioncalSubroutines: map[string]*ast.SubroutineDeclaration{ + tt.mockName: mockSubroutine, + }, + } + _, err = Testing_restore_mock( + c, + &value.String{Value: "original"}, + ) + if tt.isError { + if err == nil { + t.Errorf("Expected error but nil") + } + return + } + _, ok := c.MockedSubroutines["original"] + if ok { + t.Errorf("Expected mocked subroutine does not exist but exists") + return + } + }) + } +} diff --git a/tester/tester.go b/tester/tester.go index 88b070b5..8eaa10ea 100644 --- a/tester/tester.go +++ b/tester/tester.go @@ -192,6 +192,18 @@ func (t *Tester) runDescribedTests( return cases, err } + defer func() { + // Remove all stored subroutines + for _, sub := range d.Subroutines { + delete(defs.Subroutines, sub.Name.Value) + } + }() + + // Prepare to add subroutine definitions inside describe statement + for _, sub := range d.Subroutines { + defs.Subroutines[sub.Name.Value] = sub + } + for _, sub := range d.Subroutines { suite, scopes := t.findTestSuites(sub) for _, s := range scopes { @@ -260,7 +272,10 @@ func (t *Tester) findTestSuites(sub *ast.SubroutineDeclaration) (string, []icont an = strings.Split(strings.TrimPrefix(l, "@"), ",") } for _, s := range an { - scopes = append(scopes, icontext.ScopeByString(strings.TrimSpace(s))) + scope := icontext.ScopeByString(strings.TrimSpace(s)) + if scope != icontext.UnknownScope { + scopes = append(scopes, scope) + } } } @@ -268,7 +283,7 @@ func (t *Tester) findTestSuites(sub *ast.SubroutineDeclaration) (string, []icont return suiteName, scopes } - // If we could not determine scope from annotation, try to find from subroutine name + // If we could not determine scope from annotation, try to find from subroutine name. switch { case strings.HasSuffix(sub.Name.Value, "_recv"): scopes = append(scopes, icontext.RecvScope) @@ -320,9 +335,10 @@ func (t *Tester) setupInterpreter(defs *tf.Definiions) *interpreter.Interpreter // Factory declarations in testing VCL func (t *Tester) factoryDefinitions(vcl *ast.VCL) *tf.Definiions { defs := &tf.Definiions{ - Tables: make(map[string]*ast.TableDeclaration), - Backends: make(map[string]*value.Backend), - Acls: make(map[string]*value.Acl), + Tables: make(map[string]*ast.TableDeclaration), + Backends: make(map[string]*value.Backend), + Acls: make(map[string]*value.Acl), + Subroutines: make(map[string]*ast.SubroutineDeclaration), } for _, stmt := range vcl.Statements { @@ -340,6 +356,8 @@ func (t *Tester) factoryDefinitions(vcl *ast.VCL) *tf.Definiions { defs.Acls[t.Name.Value] = &value.Acl{ Value: t, } + case *ast.SubroutineDeclaration: + defs.Subroutines[t.Name.Value] = t } } return defs