package execution_test import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/execution" "github.com/zitadel/zitadel/internal/zerrors" ) func Test_Call(t *testing.T) { type args struct { ctx context.Context timeout time.Duration sleep time.Duration method string body []byte respBody []byte statusCode int } type res struct { body []byte wantErr bool } tests := []struct { name string args args res res }{ { "not ok status", args{ ctx: context.Background(), timeout: time.Minute, sleep: time.Second, method: http.MethodPost, body: []byte("{\"request\": \"values\"}"), respBody: []byte("{\"response\": \"values\"}"), statusCode: http.StatusBadRequest, }, res{ wantErr: true, }, }, { "timeout", args{ ctx: context.Background(), timeout: time.Second, sleep: time.Second, method: http.MethodPost, body: []byte("{\"request\": \"values\"}"), respBody: []byte("{\"response\": \"values\"}"), statusCode: http.StatusOK, }, res{ wantErr: true, }, }, { "ok", args{ ctx: context.Background(), timeout: time.Minute, sleep: time.Second, method: http.MethodPost, body: []byte("{\"request\": \"values\"}"), respBody: []byte("{\"response\": \"values\"}"), statusCode: http.StatusOK, }, res{ body: []byte("{\"response\": \"values\"}"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { respBody, err := testServer(t, &callTestServer{ method: tt.args.method, expectBody: tt.args.body, timeout: tt.args.sleep, statusCode: tt.args.statusCode, respondBody: tt.args.respBody, }, testCall(tt.args.ctx, tt.args.timeout, tt.args.body), ) if tt.res.wantErr { assert.Error(t, err) assert.Nil(t, respBody) } else { assert.NoError(t, err) assert.Equal(t, tt.res.body, respBody) } }) } } func Test_CallTarget(t *testing.T) { type args struct { ctx context.Context info *middleware.ContextInfoRequest server *callTestServer target *mockTarget } type res struct { body []byte wantErr bool } tests := []struct { name string args args res res }{ { "unknown targettype, error", args{ ctx: context.Background(), info: requestContextInfo1, server: &callTestServer{ method: http.MethodPost, expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), respondBody: []byte("{\"request\":\"content2\"}"), timeout: time.Second, statusCode: http.StatusInternalServerError, }, target: &mockTarget{ TargetType: 4, }, }, res{ wantErr: true, }, }, { "webhook, error", args{ ctx: context.Background(), info: requestContextInfo1, server: &callTestServer{ timeout: time.Second, method: http.MethodPost, expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), respondBody: []byte("{\"request\":\"content2\"}"), statusCode: http.StatusInternalServerError, }, target: &mockTarget{ TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, }, }, res{ wantErr: true, }, }, { "webhook, ok", args{ ctx: context.Background(), info: requestContextInfo1, server: &callTestServer{ timeout: time.Second, method: http.MethodPost, expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), respondBody: []byte("{\"request\":\"content2\"}"), statusCode: http.StatusOK, }, target: &mockTarget{ TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, }, }, res{ body: nil, }, }, { "request response, error", args{ ctx: context.Background(), info: requestContextInfo1, server: &callTestServer{ timeout: time.Second, method: http.MethodPost, expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), respondBody: []byte("{\"request\":\"content2\"}"), statusCode: http.StatusInternalServerError, }, target: &mockTarget{ TargetType: domain.TargetTypeCall, Timeout: time.Minute, }, }, res{ wantErr: true, }, }, { "request response, ok", args{ ctx: context.Background(), info: requestContextInfo1, server: &callTestServer{ timeout: time.Second, method: http.MethodPost, expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), respondBody: []byte("{\"request\":\"content2\"}"), statusCode: http.StatusOK, }, target: &mockTarget{ TargetType: domain.TargetTypeCall, Timeout: time.Minute, }, }, res{ body: []byte("{\"request\":\"content2\"}"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { respBody, err := testServer(t, tt.args.server, testCallTarget(tt.args.ctx, tt.args.info, tt.args.target)) if tt.res.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.res.body, respBody) }) } } func Test_CallTargets(t *testing.T) { type args struct { ctx context.Context info *middleware.ContextInfoRequest servers []*callTestServer targets []*mockTarget } type res struct { ret interface{} wantErr bool } tests := []struct { name string args args res res }{ { "interrupt on status", args{ ctx: context.Background(), info: requestContextInfo1, servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }, { timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }}, targets: []*mockTarget{ {InterruptOnError: false}, {InterruptOnError: true}, }, }, res{ wantErr: true, }, }, { "continue on status", args{ ctx: context.Background(), info: requestContextInfo1, servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }, { timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: requestContextInfoBody2, statusCode: http.StatusInternalServerError, }}, targets: []*mockTarget{ {InterruptOnError: false}, {InterruptOnError: false}, }, }, res{ ret: requestContextInfo1.GetContent(), }, }, { "interrupt on json error", args{ ctx: context.Background(), info: requestContextInfo1, servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: requestContextInfoBody2, statusCode: http.StatusOK, }, { timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: []byte("just a string, not json"), statusCode: http.StatusOK, }}, targets: []*mockTarget{ {InterruptOnError: false}, {InterruptOnError: true}, }, }, res{ wantErr: true, }, }, { "continue on json error", args{ ctx: context.Background(), info: requestContextInfo1, servers: []*callTestServer{{ timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: requestContextInfoBody2, statusCode: http.StatusOK, }, { timeout: time.Second, method: http.MethodPost, expectBody: requestContextInfoBody1, respondBody: []byte("just a string, not json"), statusCode: http.StatusOK, }}, targets: []*mockTarget{ {InterruptOnError: false}, {InterruptOnError: false}, }}, res{ ret: requestContextInfo1.GetContent(), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { respBody, err := testServers(t, tt.args.servers, testCallTargets(tt.args.ctx, tt.args.info, tt.args.targets), ) if tt.res.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.res.ret, respBody) }) } } var _ execution.Target = &mockTarget{} type mockTarget struct { InstanceID string ExecutionID string TargetID string TargetType domain.TargetType Endpoint string Timeout time.Duration InterruptOnError bool } func (e *mockTarget) GetTargetID() string { return e.TargetID } func (e *mockTarget) IsInterruptOnError() bool { return e.InterruptOnError } func (e *mockTarget) GetEndpoint() string { return e.Endpoint } func (e *mockTarget) GetTargetType() domain.TargetType { return e.TargetType } func (e *mockTarget) GetTimeout() time.Duration { return e.Timeout } type callTestServer struct { method string expectBody []byte timeout time.Duration statusCode int respondBody []byte } func testServers( t *testing.T, c []*callTestServer, call func([]string) (interface{}, error), ) (interface{}, error) { urls := make([]string, len(c)) for i := range c { url, close := listen(t, c[i]) defer close() urls[i] = url } return call(urls) } func testServer( t *testing.T, c *callTestServer, call func(string) ([]byte, error), ) ([]byte, error) { url, close := listen(t, c) defer close() return call(url) } func listen( t *testing.T, c *callTestServer, ) (url string, close func()) { handler := func(w http.ResponseWriter, r *http.Request) { checkRequest(t, r, c.method, c.expectBody) if c.statusCode != http.StatusOK { http.Error(w, "error", c.statusCode) return } time.Sleep(c.timeout) w.Header().Set("Content-Type", "application/json") if _, err := io.WriteString(w, string(c.respondBody)); err != nil { http.Error(w, "error", http.StatusInternalServerError) return } } server := httptest.NewServer(http.HandlerFunc(handler)) return server.URL, server.Close } func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte) { sentBody, err := io.ReadAll(sent.Body) require.NoError(t, err) require.Equal(t, expectedBody, sentBody) require.Equal(t, method, sent.Method) } func testCall(ctx context.Context, timeout time.Duration, body []byte) func(string) ([]byte, error) { return func(url string) ([]byte, error) { return execution.Call(ctx, url, timeout, body) } } func testCallTarget(ctx context.Context, info *middleware.ContextInfoRequest, target *mockTarget, ) func(string) ([]byte, error) { return func(url string) (r []byte, err error) { target.Endpoint = url return execution.CallTarget(ctx, target, info) } } func testCallTargets(ctx context.Context, info *middleware.ContextInfoRequest, target []*mockTarget, ) func([]string) (interface{}, error) { return func(urls []string) (interface{}, error) { targets := make([]execution.Target, len(target)) for i, t := range target { t.Endpoint = urls[i] targets[i] = t } return execution.CallTargets(ctx, targets, info) } } var requestContextInfo1 = &middleware.ContextInfoRequest{ Request: &request{ Request: "content1", }, } var requestContextInfoBody1 = []byte("{\"request\":{\"request\":\"content1\"}}") var requestContextInfoBody2 = []byte("{\"request\":{\"request\":\"content2\"}}") type request struct { Request string `json:"request"` } func testErrorBody(code int, message string) []byte { body := &execution.ErrorBody{ForwardedStatusCode: code, ForwardedErrorMessage: message} data, _ := json.Marshal(body) return data } func Test_handleResponse(t *testing.T) { type args struct { resp *http.Response } type res struct { data []byte wantErr func(error) bool } tests := []struct { name string args args res res }{ { "response, statuscode unknown and body", args{ resp: &http.Response{ StatusCode: 1000, Body: io.NopCloser(bytes.NewReader([]byte(""))), }, }, res{ wantErr: func(err error) bool { return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")) }, }, }, { "response, statuscode >= 400 and no body", args{ resp: &http.Response{ StatusCode: http.StatusForbidden, Body: io.NopCloser(bytes.NewReader([]byte(""))), }, }, res{ wantErr: func(err error) bool { return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")) }, }, }, { "response, statuscode >= 400 and body", args{ resp: &http.Response{ StatusCode: http.StatusForbidden, Body: io.NopCloser(bytes.NewReader([]byte("body"))), }, }, res{ wantErr: func(err error) bool { return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-dra6yamk98", "Errors.Execution.Failed")) }}, }, { "response, statuscode = 200 and body", args{ resp: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte("body"))), }, }, res{ data: []byte("body"), wantErr: nil, }, }, { "response, statuscode = 200 no body", args{ resp: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(""))), }, }, res{ data: []byte(""), wantErr: nil, }, }, { "response, statuscode = 200, error body >= 400 < 500", args{ resp: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusForbidden, "forbidden"))), }, }, res{ wantErr: func(err error) bool { return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "EXEC-reUaUZCzCp", "forbidden")) }, }, }, { "response, statuscode = 200, error body >= 500", args{ resp: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusInternalServerError, "internal"))), }, }, res{ wantErr: func(err error) bool { return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", "internal")) }, }, }, { "response, statuscode = 308, no body, should not happen", args{ resp: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(testErrorBody(http.StatusPermanentRedirect, "redirect"))), }, }, res{ wantErr: func(err error) bool { return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "EXEC-bmhNhpcqpF", "redirect")) }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { respBody, err := execution.HandleResponse( tt.args.resp, ) if tt.res.wantErr == nil { if !assert.NoError(t, err) { t.FailNow() } } else if !tt.res.wantErr(err) { t.Errorf("got wrong err: %v", err) return } assert.Equal(t, tt.res.data, respBody) }) } }