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 }