go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/server/cfgmodule/handler_test.go (about)

     1  // Copyright 2017 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package cfgmodule
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"io"
    23  	"net/http"
    24  	"net/http/httptest"
    25  	"strings"
    26  	"testing"
    27  
    28  	"github.com/klauspost/compress/gzip"
    29  	"google.golang.org/grpc/codes"
    30  	"google.golang.org/grpc/status"
    31  	"google.golang.org/protobuf/types/known/emptypb"
    32  
    33  	"go.chromium.org/luci/auth/identity"
    34  	"go.chromium.org/luci/common/proto/config"
    35  	"go.chromium.org/luci/config/validation"
    36  	"go.chromium.org/luci/server/auth"
    37  	"go.chromium.org/luci/server/auth/authtest"
    38  	"go.chromium.org/luci/server/router"
    39  
    40  	. "github.com/smartystreets/goconvey/convey"
    41  	. "go.chromium.org/luci/common/testing/assertions"
    42  )
    43  
    44  func TestInstallHandlers(t *testing.T) {
    45  	t.Parallel()
    46  
    47  	Convey("Initialization of validator, validation routes and handlers", t, func() {
    48  		rules := validation.NewRuleSet()
    49  
    50  		r := router.New()
    51  		rr := httptest.NewRecorder()
    52  		host := "example.com"
    53  
    54  		metaCall := func() *config.ServiceDynamicMetadata {
    55  			req, err := http.NewRequest("GET", "https://"+host+metadataPath, nil)
    56  			So(err, ShouldBeNil)
    57  			r.ServeHTTP(rr, req)
    58  
    59  			var resp config.ServiceDynamicMetadata
    60  			err = json.NewDecoder(rr.Body).Decode(&resp)
    61  			So(err, ShouldBeNil)
    62  			return &resp
    63  		}
    64  		valCall := func(configSet, path, content string) *config.ValidationResponseMessage {
    65  			respBodyJSON, err := json.Marshal(config.ValidationRequestMessage{
    66  				ConfigSet: configSet,
    67  				Path:      path,
    68  				Content:   []byte(content),
    69  			})
    70  			So(err, ShouldBeNil)
    71  			req, err := http.NewRequest("POST", validationPath, bytes.NewReader(respBodyJSON))
    72  			So(err, ShouldBeNil)
    73  			r.ServeHTTP(rr, req)
    74  			if rr.Code != http.StatusOK {
    75  				return nil
    76  			}
    77  			var resp config.ValidationResponseMessage
    78  			err = json.NewDecoder(rr.Body).Decode(&resp)
    79  			So(err, ShouldBeNil)
    80  			return &resp
    81  		}
    82  
    83  		InstallHandlers(r, nil, rules)
    84  
    85  		Convey("Basic metadataHandler call", func() {
    86  			So(rr.Code, ShouldEqual, http.StatusOK)
    87  			So(metaCall(), ShouldResemble, &config.ServiceDynamicMetadata{
    88  				Version:                 metaDataFormatVersion,
    89  				SupportsGzipCompression: true,
    90  				Validation: &config.Validator{
    91  					Url: fmt.Sprintf("https://%s%s", host, validationPath),
    92  				},
    93  			})
    94  		})
    95  
    96  		Convey("metadataHandler call with patterns", func() {
    97  			rules.Add("configSet", "path", nil)
    98  			meta := metaCall()
    99  			So(rr.Code, ShouldEqual, http.StatusOK)
   100  			So(meta, ShouldResemble, &config.ServiceDynamicMetadata{
   101  				Version:                 metaDataFormatVersion,
   102  				SupportsGzipCompression: true,
   103  				Validation: &config.Validator{
   104  					Url: fmt.Sprintf("https://%s%s", host, validationPath),
   105  					Patterns: []*config.ConfigPattern{
   106  						{
   107  							ConfigSet: "exact:configSet",
   108  							Path:      "exact:path",
   109  						},
   110  					},
   111  				},
   112  			})
   113  		})
   114  
   115  		Convey("Basic validationHandler call", func() {
   116  			rules.Add("dead", "beef", func(ctx *validation.Context, configSet, path string, content []byte) error {
   117  				So(string(content), ShouldEqual, "content")
   118  				ctx.Errorf("blocking error")
   119  				ctx.Warningf("diagnostic warning")
   120  				return nil
   121  			})
   122  			valResp := valCall("dead", "beef", "content")
   123  			So(rr.Code, ShouldEqual, http.StatusOK)
   124  			So(valResp, ShouldResemble, &config.ValidationResponseMessage{
   125  				Messages: []*config.ValidationResponseMessage_Message{
   126  					{
   127  						Text:     "in \"beef\": blocking error",
   128  						Severity: config.ValidationResponseMessage_ERROR,
   129  					},
   130  					{
   131  						Text:     "in \"beef\": diagnostic warning",
   132  						Severity: config.ValidationResponseMessage_WARNING,
   133  					},
   134  				},
   135  			})
   136  		})
   137  
   138  		Convey("validationHandler call with no configSet or path", func() {
   139  			valCall("", "", "")
   140  			So(rr.Code, ShouldEqual, http.StatusBadRequest)
   141  			So(rr.Body.String(), ShouldEqual, "Must specify the config_set of the file to validate")
   142  		})
   143  
   144  		Convey("validationHandler call with no path", func() {
   145  			valCall("dead", "", "")
   146  			So(rr.Code, ShouldEqual, http.StatusBadRequest)
   147  			So(rr.Body.String(), ShouldEqual, "Must specify the path of the file to validate")
   148  		})
   149  	})
   150  }
   151  
   152  func TestConsumerServer(t *testing.T) {
   153  	t.Parallel()
   154  
   155  	Convey("ConsumerServer", t, func() {
   156  		const configSA = "luci-config-service@luci-config.iam.gserviceaccount.com"
   157  		authState := &authtest.FakeState{
   158  			Identity: "user:" + configSA,
   159  		}
   160  		ctx := authtest.MockAuthConfig(context.Background())
   161  		ctx = auth.WithState(ctx, authState)
   162  		rules := validation.NewRuleSet()
   163  		srv := ConsumerServer{
   164  			Rules: rules,
   165  			GetConfigServiceAccountFn: func(ctx context.Context) (string, error) {
   166  				return configSA, nil
   167  			},
   168  		}
   169  
   170  		Convey("Check caller", func() {
   171  			Convey("Allow LUCI Config service account", func() {
   172  				_, err := srv.GetMetadata(ctx, &emptypb.Empty{})
   173  				So(err, ShouldBeNil)
   174  			})
   175  			Convey("Allow Admin group", func() {
   176  				authState := &authtest.FakeState{
   177  					Identity:       "user:someone@example.com",
   178  					IdentityGroups: []string{adminGroup},
   179  				}
   180  				ctx = auth.WithState(ctx, authState)
   181  				_, err := srv.GetMetadata(ctx, &emptypb.Empty{})
   182  				So(err, ShouldBeNil)
   183  			})
   184  			Convey("Disallow", func() {
   185  				Convey("Non-admin users", func() {
   186  					authState = &authtest.FakeState{
   187  						Identity: "user:someone@example.com",
   188  					}
   189  				})
   190  				Convey("Anonymous", func() {
   191  					authState = &authtest.FakeState{
   192  						Identity: identity.AnonymousIdentity,
   193  					}
   194  				})
   195  				ctx = auth.WithState(ctx, authState)
   196  				_, err := srv.GetMetadata(ctx, &emptypb.Empty{})
   197  				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   198  			})
   199  		})
   200  
   201  		Convey("GetMetadata", func() {
   202  			rules.Add("configSet", "path", nil)
   203  			res, err := srv.GetMetadata(ctx, &emptypb.Empty{})
   204  			So(err, ShouldBeNil)
   205  			So(res, ShouldResembleProto, &config.ServiceMetadata{
   206  				ConfigPatterns: []*config.ConfigPattern{
   207  					{
   208  						ConfigSet: "exact:configSet",
   209  						Path:      "exact:path",
   210  					},
   211  				},
   212  			})
   213  		})
   214  
   215  		Convey("ValidateConfig", func() {
   216  			const configSet = "project/xyz"
   217  			addRule := func(path string) {
   218  				rules.Add(configSet, path, func(ctx *validation.Context, configSet, path string, content []byte) error {
   219  					if bytes.Contains(content, []byte("good")) {
   220  						return nil
   221  					}
   222  					if bytes.Contains(content, []byte("error")) {
   223  						ctx.Errorf("blocking error")
   224  					}
   225  					if bytes.Contains(content, []byte("warning")) {
   226  						ctx.Warningf("diagnostic warning")
   227  					}
   228  					return nil
   229  				})
   230  			}
   231  			resources := map[string]struct {
   232  				data    []byte
   233  				gzipped bool
   234  			}{}
   235  
   236  			addFileToRemote := func(path string, data []byte, compress bool) {
   237  				if compress {
   238  					var b bytes.Buffer
   239  					gw := gzip.NewWriter(&b)
   240  					_, err := gw.Write(data)
   241  					So(err, ShouldBeNil)
   242  					So(gw.Close(), ShouldBeNil)
   243  					data = b.Bytes()
   244  				}
   245  				resources[path] = struct {
   246  					data    []byte
   247  					gzipped bool
   248  				}{
   249  					data:    data,
   250  					gzipped: compress,
   251  				}
   252  			}
   253  
   254  			ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   255  				path := strings.TrimLeft(r.RequestURI, "/")
   256  				var resp []byte
   257  				switch resource, ok := resources[path]; {
   258  				case !ok:
   259  					http.Error(w, fmt.Sprintf("Unknown resource %q", path), http.StatusNotFound)
   260  				case r.Header.Get("Accept-Encoding") == "gzip" && resource.gzipped:
   261  					w.Header().Add("Content-Encoding", "gzip")
   262  					resp = resource.data
   263  				case resource.gzipped:
   264  					gr, err := gzip.NewReader(bytes.NewBuffer(resource.data))
   265  					if err != nil {
   266  						http.Error(w, fmt.Sprintf("failed to create reader %s", err), http.StatusInternalServerError)
   267  						return
   268  					}
   269  					defer func() { _ = gr.Close() }()
   270  					resp, err = io.ReadAll(gr)
   271  					if err != nil {
   272  						http.Error(w, fmt.Sprintf("failed to read data %s", err), http.StatusInternalServerError)
   273  						return
   274  					}
   275  				default:
   276  					resp = resource.data
   277  				}
   278  				_, err := w.Write(resp)
   279  				if err != nil {
   280  					panic(err)
   281  				}
   282  			}))
   283  			defer ts.Close()
   284  
   285  			Convey("Single file", func() {
   286  				const path = "some_file.cfg"
   287  				addRule(path)
   288  				file := &config.ValidateConfigsRequest_File{
   289  					Path: path,
   290  				}
   291  				req := &config.ValidateConfigsRequest{
   292  					ConfigSet: configSet,
   293  					Files: &config.ValidateConfigsRequest_Files{
   294  						Files: []*config.ValidateConfigsRequest_File{
   295  							file,
   296  						},
   297  					},
   298  				}
   299  				Convey("Pass validation", func() {
   300  					Convey("With raw content", func() {
   301  						file.Content = &config.ValidateConfigsRequest_File_RawContent{
   302  							RawContent: []byte("good config"),
   303  						}
   304  					})
   305  					Convey("With signed url", func() {
   306  						addFileToRemote(path, []byte("good config"), true)
   307  						file.Content = &config.ValidateConfigsRequest_File_SignedUrl{
   308  							SignedUrl: fmt.Sprintf("%s/%s", ts.URL, path),
   309  						}
   310  					})
   311  					res, err := srv.ValidateConfigs(ctx, req)
   312  					So(err, ShouldBeNil)
   313  					So(res.GetMessages(), ShouldBeEmpty)
   314  				})
   315  				Convey("With error", func() {
   316  					Convey("With raw content", func() {
   317  						file.Content = &config.ValidateConfigsRequest_File_RawContent{
   318  							RawContent: []byte("config with error"),
   319  						}
   320  					})
   321  					Convey("With signed url", func() {
   322  						addFileToRemote(path, []byte("config with error"), true)
   323  						file.Content = &config.ValidateConfigsRequest_File_SignedUrl{
   324  							SignedUrl: fmt.Sprintf("%s/%s", ts.URL, path),
   325  						}
   326  					})
   327  					res, err := srv.ValidateConfigs(ctx, req)
   328  					So(err, ShouldBeNil)
   329  					So(res, ShouldResembleProto, &config.ValidationResult{
   330  						Messages: []*config.ValidationResult_Message{
   331  							{
   332  								Path:     path,
   333  								Text:     "in \"some_file.cfg\": blocking error",
   334  								Severity: config.ValidationResult_ERROR,
   335  							},
   336  						},
   337  					})
   338  				})
   339  				Convey("With warning", func() {
   340  					Convey("With raw content", func() {
   341  						file.Content = &config.ValidateConfigsRequest_File_RawContent{
   342  							RawContent: []byte("config with warning"),
   343  						}
   344  					})
   345  					Convey("With signed url", func() {
   346  						addFileToRemote(path, []byte("config with warning"), true)
   347  						file.Content = &config.ValidateConfigsRequest_File_SignedUrl{
   348  							SignedUrl: fmt.Sprintf("%s/%s", ts.URL, path),
   349  						}
   350  					})
   351  					res, err := srv.ValidateConfigs(ctx, req)
   352  					So(err, ShouldBeNil)
   353  					So(res, ShouldResembleProto, &config.ValidationResult{
   354  						Messages: []*config.ValidationResult_Message{
   355  							{
   356  								Path:     path,
   357  								Text:     "in \"some_file.cfg\": diagnostic warning",
   358  								Severity: config.ValidationResult_WARNING,
   359  							},
   360  						},
   361  					})
   362  				})
   363  				Convey("With both", func() {
   364  					Convey("With raw content", func() {
   365  						file.Content = &config.ValidateConfigsRequest_File_RawContent{
   366  							RawContent: []byte("config with error and warning"),
   367  						}
   368  					})
   369  					Convey("With signed url", func() {
   370  						addFileToRemote(path, []byte("config with error and warning"), true)
   371  						file.Content = &config.ValidateConfigsRequest_File_SignedUrl{
   372  							SignedUrl: fmt.Sprintf("%s/%s", ts.URL, path),
   373  						}
   374  					})
   375  					res, err := srv.ValidateConfigs(ctx, req)
   376  					So(err, ShouldBeNil)
   377  					So(res, ShouldResembleProto, &config.ValidationResult{
   378  						Messages: []*config.ValidationResult_Message{
   379  							{
   380  								Path:     path,
   381  								Text:     "in \"some_file.cfg\": blocking error",
   382  								Severity: config.ValidationResult_ERROR,
   383  							},
   384  							{
   385  								Path:     path,
   386  								Text:     "in \"some_file.cfg\": diagnostic warning",
   387  								Severity: config.ValidationResult_WARNING,
   388  							},
   389  						},
   390  					})
   391  				})
   392  
   393  				Convey("Signed Url not found", func() {
   394  					// Without adding the file to remote
   395  					file.Content = &config.ValidateConfigsRequest_File_SignedUrl{
   396  						SignedUrl: fmt.Sprintf("%s/%s", ts.URL, path),
   397  					}
   398  					res, err := srv.ValidateConfigs(ctx, req)
   399  					grpcStatus, ok := status.FromError(err)
   400  					So(ok, ShouldBeTrue)
   401  					So(grpcStatus, ShouldBeLikeStatus, codes.Internal, "Unknown resource")
   402  					So(res, ShouldBeNil)
   403  				})
   404  			})
   405  
   406  			Convey("Multiple files", func() {
   407  				addRule("foo.cfg")
   408  				addRule("bar.cfg")
   409  				addRule("baz.cfg")
   410  				fileFoo := &config.ValidateConfigsRequest_File{
   411  					Path: "foo.cfg",
   412  					Content: &config.ValidateConfigsRequest_File_RawContent{
   413  						RawContent: []byte("error"),
   414  					},
   415  				}
   416  				addFileToRemote("bar.cfg", []byte("good config"), true)
   417  				fileBar := &config.ValidateConfigsRequest_File{
   418  					Path: "bar.cfg",
   419  					Content: &config.ValidateConfigsRequest_File_SignedUrl{
   420  						SignedUrl: ts.URL + "/bar.cfg",
   421  					},
   422  				}
   423  				addFileToRemote("baz.cfg", []byte("warning and error"), false) // not compressed at rest
   424  				fileBaz := &config.ValidateConfigsRequest_File{
   425  					Path: "baz.cfg",
   426  					Content: &config.ValidateConfigsRequest_File_SignedUrl{
   427  						SignedUrl: ts.URL + "/baz.cfg",
   428  					},
   429  				}
   430  
   431  				req := &config.ValidateConfigsRequest{
   432  					ConfigSet: configSet,
   433  					Files: &config.ValidateConfigsRequest_Files{
   434  						Files: []*config.ValidateConfigsRequest_File{
   435  							fileFoo, fileBar, fileBaz,
   436  						},
   437  					},
   438  				}
   439  
   440  				res, err := srv.ValidateConfigs(ctx, req)
   441  				So(err, ShouldBeNil)
   442  				So(res, ShouldResembleProto, &config.ValidationResult{
   443  					Messages: []*config.ValidationResult_Message{
   444  						{
   445  							Path:     "foo.cfg",
   446  							Text:     "in \"foo.cfg\": blocking error",
   447  							Severity: config.ValidationResult_ERROR,
   448  						},
   449  						{
   450  							Path:     "baz.cfg",
   451  							Text:     "in \"baz.cfg\": blocking error",
   452  							Severity: config.ValidationResult_ERROR,
   453  						},
   454  						{
   455  							Path:     "baz.cfg",
   456  							Text:     "in \"baz.cfg\": diagnostic warning",
   457  							Severity: config.ValidationResult_WARNING,
   458  						},
   459  					},
   460  				})
   461  			})
   462  		})
   463  	})
   464  }