From 557ed6e9d6e6558f25448c94a8372924e0f3b86f Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sun, 27 Sep 2026 16:56:08 +0530 Subject: [PATCH 1/2] fix(aws-appsync): list caps, key ids and config cross-validation --- providers/aws/appsync/apikey.go | 15 +- providers/aws/appsync/appsync.go | 5 +- providers/aws/appsync/appsync_test.go | 1 + .../aws/appsync/config_validation_test.go | 207 ++++++++++++++ providers/aws/appsync/datasource.go | 34 +-- providers/aws/appsync/graphqlapi.go | 12 + providers/aws/appsync/list_caps_test.go | 110 ++++++++ providers/aws/appsync/resource_ids_test.go | 57 ++++ providers/aws/appsync/validation.go | 264 ++++++++++++++++++ server/aws/appsync/handler.go | 7 + server/aws/appsync/validation_wire_test.go | 113 ++++++++ services/appsync/driver/driver.go | 17 +- 12 files changed, 813 insertions(+), 29 deletions(-) create mode 100644 providers/aws/appsync/config_validation_test.go create mode 100644 providers/aws/appsync/list_caps_test.go create mode 100644 providers/aws/appsync/resource_ids_test.go create mode 100644 providers/aws/appsync/validation.go create mode 100644 server/aws/appsync/validation_wire_test.go diff --git a/providers/aws/appsync/apikey.go b/providers/aws/appsync/apikey.go index 0f3272e29..7ed5f6776 100644 --- a/providers/aws/appsync/apikey.go +++ b/providers/aws/appsync/apikey.go @@ -18,6 +18,9 @@ const ( apiKeyDeleteGrace = 60 * 24 * time.Hour ) +// apiKeyIDPrefix marks the current (da2) API-key version. +const apiKeyIDPrefix = "da2-" + // CreateAPIKey mints an API key whose expiry is computed once via the clock and // floored to the hour, then never recomputed on a read. func (m *Mock) CreateAPIKey(_ context.Context, in *driver.CreateAPIKeyInput) (*driver.APIKey, error) { @@ -32,7 +35,7 @@ func (m *Mock) CreateAPIKey(_ context.Context, in *driver.CreateAPIKeyInput) (*d } key := driver.APIKey{ - ID: idgen.GenerateID("da2-"), + ID: newAPIKeyID(), Description: in.Description, Expires: expires, Deletes: deletes, @@ -48,6 +51,10 @@ func (m *Mock) CreateAPIKey(_ context.Context, in *driver.CreateAPIKeyInput) (*d // ListAPIKeys returns a deterministic, deep-copied page of an API's keys, // ordered by id. Stored expiry values are returned as-is (never recomputed). func (m *Mock) ListAPIKeys(_ context.Context, apiID string, page driver.Page) ([]driver.APIKey, string, error) { + if err := validatePage(page); err != nil { + return nil, "", err + } + ad, err := m.getAPI(apiID) if err != nil { return nil, "", err @@ -116,6 +123,12 @@ func (m *Mock) DeleteAPIKey(_ context.Context, apiID, id string) error { return nil } +// newAPIKeyID mints a da2 API-key id: "da2-" plus 26 lowercase alphanumeric +// characters, the same random shape AppSync uses for an apiId. +func newAPIKeyID() string { + return apiKeyIDPrefix + idgen.AppSyncAPIID() +} + // computeExpiry resolves an API-key expiry (epoch seconds) and its deletion // time. A zero request selects the default 7-day validity; a supplied value // must fall within [now+1d, now+365d] or APIKeyValidityOutOfBoundsException is diff --git a/providers/aws/appsync/appsync.go b/providers/aws/appsync/appsync.go index 0f553a5cd..465003c84 100644 --- a/providers/aws/appsync/appsync.go +++ b/providers/aws/appsync/appsync.go @@ -21,9 +21,6 @@ import ( // Compile-time check that Mock implements driver.AppSync. var _ driver.AppSync = (*Mock)(nil) -// defaultMaxResults caps a page when the caller requests none. -const defaultMaxResults = 100 - // apiData is the full server-side state of one GraphQL API plus its own lock. // The nested data-source and API-key maps are guarded by mu. type apiData struct { @@ -137,7 +134,7 @@ func paginate(n int, page driver.Page) (start, end int, next string) { limit := int(page.MaxResults) if limit <= 0 { - limit = defaultMaxResults + limit = maxListResults } end = start + limit diff --git a/providers/aws/appsync/appsync_test.go b/providers/aws/appsync/appsync_test.go index 0ddaa16fe..ab0c34b84 100644 --- a/providers/aws/appsync/appsync_test.go +++ b/providers/aws/appsync/appsync_test.go @@ -326,6 +326,7 @@ func TestUpdateDataSource(t *testing.T) { upd, err := m.UpdateDataSource(ctx, &driver.UpdateDataSourceInput{ APIID: api.APIID, Name: "src", Type: driver.DataSourceHTTP, Description: "changed", + Extra: raw("httpConfig", `{"endpoint":"https://example.com"}`), }) if err != nil { t.Fatalf("UpdateDataSource: %v", err) diff --git a/providers/aws/appsync/config_validation_test.go b/providers/aws/appsync/config_validation_test.go new file mode 100644 index 000000000..2eef1410a --- /dev/null +++ b/providers/aws/appsync/config_validation_test.go @@ -0,0 +1,207 @@ +package appsync_test + +import ( + "context" + "encoding/json" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/appsync/driver" +) + +const ( + userPoolJSON = `{"userPoolId":"us-east-1_abc","awsRegion":"us-east-1","defaultAction":"ALLOW"}` + oidcJSON = `{"issuer":"https://issuer.example.com"}` + lambdaAuth = `{"authorizerUri":"arn:aws:lambda:us-east-1:123456789012:function:auth"}` + roleARN = "arn:aws:iam::123456789012:role/appsync" + emptyString = "The validated string is empty" +) + +func raw(kv ...string) map[string]json.RawMessage { + out := map[string]json.RawMessage{} + for i := 0; i+1 < len(kv); i += 2 { + out[kv[i]] = json.RawMessage(kv[i+1]) + } + + return out +} + +func assertBadRequest(t *testing.T, err error, wantMsg string) { + t.Helper() + + assertException(t, err, driver.ExBadRequest) + + if got := cerrors.Message(err); got != wantMsg { + t.Fatalf("message = %q, want %q", got, wantMsg) + } +} + +func TestGraphqlAPIAuthConfigValidation(t *testing.T) { + cases := []struct { + name string + auth string + extra map[string]json.RawMessage + wantErr string + }{ + {"api key needs nothing", driver.AuthAPIKey, nil, ""}, + {"iam needs nothing", driver.AuthAWSIAM, nil, ""}, + {"cognito with config", driver.AuthCognito, raw("userPoolConfig", userPoolJSON), ""}, + {"cognito without config", driver.AuthCognito, nil, "UserPoolConfig can't be null."}, + {"cognito null config", driver.AuthCognito, raw("userPoolConfig", "null"), "UserPoolConfig can't be null."}, + { + "cognito bad default action", driver.AuthCognito, + raw("userPoolConfig", `{"userPoolId":"p","awsRegion":"us-east-1"}`), "Invalid default effect type", + }, + {"oidc with config", driver.AuthOpenIDConnect, raw("openIDConnectConfig", oidcJSON), ""}, + {"oidc without config", driver.AuthOpenIDConnect, nil, "OpenIDConnectConfig can't be null."}, + {"lambda with config", driver.AuthLambda, raw("lambdaAuthorizerConfig", lambdaAuth), ""}, + {"lambda without config", driver.AuthLambda, nil, "LambdaAuthorizerConfig can't be null."}, + { + "additional providers with config", driver.AuthAPIKey, + raw("additionalAuthenticationProviders", `[{"authenticationType":"AWS_IAM"},`+ + `{"authenticationType":"AMAZON_COGNITO_USER_POOLS","userPoolConfig":{"userPoolId":"p","awsRegion":"us-east-1"}},`+ + `{"authenticationType":"OPENID_CONNECT","openIDConnectConfig":`+oidcJSON+`},`+ + `{"authenticationType":"AWS_LAMBDA","lambdaAuthorizerConfig":`+lambdaAuth+`}]`), "", + }, + { + "additional cognito without config", driver.AuthAPIKey, + raw("additionalAuthenticationProviders", `[{"authenticationType":"AMAZON_COGNITO_USER_POOLS"}]`), + "UserPoolConfig can't be null.", + }, + { + "additional oidc without config", driver.AuthAPIKey, + raw("additionalAuthenticationProviders", `[{"authenticationType":"OPENID_CONNECT"}]`), + "OpenIDConnectConfig can't be null.", + }, + { + "additional lambda without config", driver.AuthAPIKey, + raw("additionalAuthenticationProviders", `[{"authenticationType":"AWS_LAMBDA"}]`), + "LambdaAuthorizerConfig can't be null.", + }, + { + "additional provider without type", driver.AuthAPIKey, + raw("additionalAuthenticationProviders", `[{}]`), "AuthenticationType can't be null.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + ctx := context.Background() + + created, err := m.CreateGraphqlAPI(ctx, &driver.CreateGraphqlAPIInput{ + Name: "api", AuthenticationType: tc.auth, Extra: tc.extra, + }) + if tc.wantErr != "" { + assertBadRequest(t, err, tc.wantErr) + } else if err != nil { + t.Fatalf("CreateGraphqlAPI: %v", err) + } + + // UpdateGraphqlAPI applies the same rules to an existing API. + base := createAPI(t, m, "base") + + _, err = m.UpdateGraphqlAPI(ctx, &driver.UpdateGraphqlAPIInput{ + APIID: base.APIID, Name: "base", AuthenticationType: tc.auth, Extra: tc.extra, + }) + if tc.wantErr != "" { + assertBadRequest(t, err, tc.wantErr) + + return + } + + if err != nil { + t.Fatalf("UpdateGraphqlAPI: %v", err) + } + + if created == nil { + t.Fatal("create returned nil") + } + }) + } +} + +func TestDataSourceConfigValidation(t *testing.T) { + const ( + ddb = `{"tableName":"t","awsRegion":"us-east-1"}` + lambda = `{"lambdaFunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:f"}` + search = `{"endpoint":"https://search.example.com","awsRegion":"us-east-1"}` + http = `{"endpoint":"https://example.com"}` + rds = `{"relationalDatabaseSourceType":"RDS_HTTP_ENDPOINT","rdsHttpEndpointConfig":{"awsRegion":"us-east-1",` + + `"dbClusterIdentifier":"arn:aws:rds:us-east-1:123456789012:cluster:c",` + + `"awsSecretStoreArn":"arn:aws:secretsmanager:us-east-1:123456789012:secret:s"}}` + bus = `{"eventBusArn":"arn:aws:events:us-east-1:123456789012:event-bus/default"}` + ) + + cases := []struct { + name string + dsType string + role string + extra map[string]json.RawMessage + wantErr string + }{ + {"none takes nothing", driver.DataSourceNone, "", nil, ""}, + {"none rejects a config", driver.DataSourceNone, "", raw("httpConfig", http), + "HttpConfig is not supported for data source type NONE."}, + {"bedrock runtime takes nothing", "AMAZON_BEDROCK_RUNTIME", roleARN, nil, ""}, + {"dynamodb ok", driver.DataSourceDynamoDB, roleARN, raw("dynamodbConfig", ddb), ""}, + {"dynamodb missing config", driver.DataSourceDynamoDB, roleARN, nil, "DynamodbConfig can't be null."}, + {"dynamodb missing table", driver.DataSourceDynamoDB, roleARN, raw("dynamodbConfig", `{"awsRegion":"us-east-1"}`), emptyString}, + {"dynamodb missing role", driver.DataSourceDynamoDB, "", raw("dynamodbConfig", ddb), emptyString}, + {"dynamodb with lambda config", driver.DataSourceDynamoDB, roleARN, raw("dynamodbConfig", ddb, "lambdaConfig", lambda), + "LambdaConfig is not supported for data source type AMAZON_DYNAMODB."}, + {"lambda ok", driver.DataSourceLambda, roleARN, raw("lambdaConfig", lambda), ""}, + {"lambda missing config", driver.DataSourceLambda, roleARN, nil, "LambdaConfig can't be null."}, + {"lambda missing role", driver.DataSourceLambda, "", raw("lambdaConfig", lambda), emptyString}, + {"opensearch ok", driver.DataSourceOpenSearch, roleARN, raw("openSearchServiceConfig", search), ""}, + {"opensearch missing config", driver.DataSourceOpenSearch, roleARN, nil, "OpenSearchServiceConfig can't be null."}, + {"elasticsearch ok", driver.DataSourceElasticsearch, roleARN, raw("elasticsearchConfig", search), ""}, + {"elasticsearch missing config", driver.DataSourceElasticsearch, roleARN, nil, "ElasticsearchConfig can't be null."}, + {"http ok without role", driver.DataSourceHTTP, "", raw("httpConfig", http), ""}, + {"http missing config", driver.DataSourceHTTP, "", nil, "HttpConfig can't be null."}, + {"http missing endpoint", driver.DataSourceHTTP, "", raw("httpConfig", `{}`), emptyString}, + {"http iam auth needs role", driver.DataSourceHTTP, "", + raw("httpConfig", `{"endpoint":"https://example.com","authorizationConfig":{"authorizationType":"AWS_IAM"}}`), emptyString}, + {"http iam auth with role", driver.DataSourceHTTP, roleARN, + raw("httpConfig", `{"endpoint":"https://example.com","authorizationConfig":{"authorizationType":"AWS_IAM"}}`), ""}, + {"rds ok", driver.DataSourceRelational, roleARN, raw("relationalDatabaseConfig", rds), ""}, + {"rds missing config", driver.DataSourceRelational, roleARN, nil, "RelationalDatabaseConfig can't be null."}, + {"rds missing endpoint config", driver.DataSourceRelational, roleARN, + raw("relationalDatabaseConfig", `{"relationalDatabaseSourceType":"RDS_HTTP_ENDPOINT"}`), "RdsHttpEndpointConfig can't be null."}, + {"eventbridge ok", driver.DataSourceEventBridge, roleARN, raw("eventBridgeConfig", bus), ""}, + {"eventbridge missing config", driver.DataSourceEventBridge, roleARN, nil, "EventBridgeConfig can't be null."}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + ctx := context.Background() + api := createAPI(t, m, "api") + + _, err := m.CreateDataSource(ctx, &driver.CreateDataSourceInput{ + APIID: api.APIID, Name: "src", Type: tc.dsType, ServiceRoleArn: tc.role, Extra: tc.extra, + }) + if tc.wantErr != "" { + assertBadRequest(t, err, tc.wantErr) + } else if err != nil { + t.Fatalf("CreateDataSource: %v", err) + } + + // UpdateDataSource applies the same rules to an existing source. + if _, err = m.CreateDataSource(ctx, &driver.CreateDataSourceInput{ + APIID: api.APIID, Name: "base", Type: driver.DataSourceNone, + }); err != nil { + t.Fatalf("CreateDataSource base: %v", err) + } + + _, err = m.UpdateDataSource(ctx, &driver.UpdateDataSourceInput{ + APIID: api.APIID, Name: "base", Type: tc.dsType, ServiceRoleArn: tc.role, Extra: tc.extra, + }) + if tc.wantErr != "" { + assertBadRequest(t, err, tc.wantErr) + } else if err != nil { + t.Fatalf("UpdateDataSource: %v", err) + } + }) + } +} diff --git a/providers/aws/appsync/datasource.go b/providers/aws/appsync/datasource.go index da2773992..20c0572c5 100644 --- a/providers/aws/appsync/datasource.go +++ b/providers/aws/appsync/datasource.go @@ -2,23 +2,12 @@ package appsync import ( "context" + "encoding/json" "sort" "github.com/stackshy/cloudemu/v2/services/appsync/driver" ) -//nolint:gochecknoglobals // immutable validation set, read-only after init. -var validDataSourceTypes = map[string]bool{ - driver.DataSourceLambda: true, - driver.DataSourceDynamoDB: true, - driver.DataSourceElasticsearch: true, - driver.DataSourceOpenSearch: true, - driver.DataSourceHTTP: true, - driver.DataSourceNone: true, - driver.DataSourceRelational: true, - driver.DataSourceEventBridge: true, -} - // CreateDataSource attaches a data source to an API, computing its stable // dataSourceArn. func (m *Mock) CreateDataSource(_ context.Context, in *driver.CreateDataSourceInput) (*driver.DataSource, error) { @@ -26,8 +15,8 @@ func (m *Mock) CreateDataSource(_ context.Context, in *driver.CreateDataSourceIn return nil, badRequest("name is required") } - if !validDataSourceTypes[in.Type] { - return nil, badRequest("data source type %q is not valid", in.Type) + if err := checkDataSource(in.Type, in.ServiceRoleArn, in.Extra); err != nil { + return nil, err } ad, err := m.getAPI(in.APIID) @@ -80,8 +69,8 @@ func (m *Mock) GetDataSource(_ context.Context, apiID, name string) (*driver.Dat // UpdateDataSource replaces the mutable fields of a data source while keeping // its computed dataSourceArn. func (m *Mock) UpdateDataSource(_ context.Context, in *driver.UpdateDataSourceInput) (*driver.DataSource, error) { - if !validDataSourceTypes[in.Type] { - return nil, badRequest("data source type %q is not valid", in.Type) + if err := checkDataSource(in.Type, in.ServiceRoleArn, in.Extra); err != nil { + return nil, err } ad, err := m.getAPI(in.APIID) @@ -129,6 +118,10 @@ func (m *Mock) DeleteDataSource(_ context.Context, apiID, name string) error { // ListDataSources returns a deterministic, deep-copied page of an API's data // sources, ordered by name. func (m *Mock) ListDataSources(_ context.Context, apiID string, page driver.Page) ([]driver.DataSource, string, error) { + if err := validatePage(page); err != nil { + return nil, "", err + } + ad, err := m.getAPI(apiID) if err != nil { return nil, "", err @@ -143,6 +136,15 @@ func (m *Mock) ListDataSources(_ context.Context, apiID string, page driver.Page return all[start:end], next, nil } +// checkDataSource validates a data source's type and its type-specific config. +func checkDataSource(dsType, serviceRoleArn string, extra map[string]json.RawMessage) error { + if _, ok := dataSourceRules[dsType]; !ok { + return badRequest("data source type %q is not valid", dsType) + } + + return validateDataSourceConfig(dsType, serviceRoleArn, extra) +} + func sortedDataSources(m map[string]driver.DataSource) []driver.DataSource { names := make([]string, 0, len(m)) for name := range m { diff --git a/providers/aws/appsync/graphqlapi.go b/providers/aws/appsync/graphqlapi.go index ddb8a924e..4a0c0c833 100644 --- a/providers/aws/appsync/graphqlapi.go +++ b/providers/aws/appsync/graphqlapi.go @@ -27,6 +27,10 @@ func (m *Mock) CreateGraphqlAPI(_ context.Context, in *driver.CreateGraphqlAPIIn return nil, badRequest("authenticationType %q is not valid", in.AuthenticationType) } + if err := validateAuthConfig(in.AuthenticationType, in.Extra); err != nil { + return nil, err + } + apiID := newAPIID() api := driver.GraphqlAPI{ @@ -79,6 +83,10 @@ func (m *Mock) UpdateGraphqlAPI(_ context.Context, in *driver.UpdateGraphqlAPIIn return nil, badRequest("authenticationType %q is not valid", in.AuthenticationType) } + if err := validateAuthConfig(in.AuthenticationType, in.Extra); err != nil { + return nil, err + } + ad, err := m.getAPI(in.APIID) if err != nil { return nil, err @@ -111,6 +119,10 @@ func (m *Mock) DeleteGraphqlAPI(_ context.Context, apiID string) error { // ListGraphqlAPIs returns a deterministic, deep-copied page of the APIs. func (m *Mock) ListGraphqlAPIs(_ context.Context, page driver.Page) ([]driver.GraphqlAPI, string, error) { + if err := validatePage(page); err != nil { + return nil, "", err + } + ads := m.apis.SortedValues() all := make([]driver.GraphqlAPI, 0, len(ads)) diff --git a/providers/aws/appsync/list_caps_test.go b/providers/aws/appsync/list_caps_test.go new file mode 100644 index 000000000..bdf55013f --- /dev/null +++ b/providers/aws/appsync/list_caps_test.go @@ -0,0 +1,110 @@ +package appsync_test + +import ( + "context" + "fmt" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/aws/appsync" + "github.com/stackshy/cloudemu/v2/services/appsync/driver" +) + +// listFn runs one AppSync List operation and returns the page length and token. +type listFn func(ctx context.Context, page driver.Page) (int, string, error) + +// listOps builds every List operation against one API that already holds n +// data sources and n API keys, with n APIs in total. +func listOps(t *testing.T, n int) (*appsync.Mock, map[string]listFn) { + t.Helper() + + m := newMock(t) + ctx := context.Background() + api := createAPI(t, m, "api-0") + + for i := 1; i < n; i++ { + createAPI(t, m, fmt.Sprintf("api-%d", i)) + } + + for i := 0; i < n; i++ { + if _, err := m.CreateDataSource(ctx, &driver.CreateDataSourceInput{ + APIID: api.APIID, Name: fmt.Sprintf("src%d", i), Type: driver.DataSourceNone, + }); err != nil { + t.Fatalf("CreateDataSource: %v", err) + } + + if _, err := m.CreateAPIKey(ctx, &driver.CreateAPIKeyInput{APIID: api.APIID}); err != nil { + t.Fatalf("CreateAPIKey: %v", err) + } + } + + return m, map[string]listFn{ + "ListGraphqlApis": func(ctx context.Context, p driver.Page) (int, string, error) { + out, next, err := m.ListGraphqlAPIs(ctx, p) + return len(out), next, err + }, + "ListDataSources": func(ctx context.Context, p driver.Page) (int, string, error) { + out, next, err := m.ListDataSources(ctx, api.APIID, p) + return len(out), next, err + }, + "ListApiKeys": func(ctx context.Context, p driver.Page) (int, string, error) { + out, next, err := m.ListAPIKeys(ctx, api.APIID, p) + return len(out), next, err + }, + } +} + +func TestListOpsRejectMaxResultsOver25(t *testing.T) { + _, ops := listOps(t, 1) + + const want = "1 validation error detected: Value '26' at 'maxResults' failed to satisfy constraint: " + + "Member must have value less than or equal to 25" + + for name, list := range ops { + t.Run(name, func(t *testing.T) { + _, _, err := list(context.Background(), driver.Page{MaxResults: 26}) + assertException(t, err, driver.ExBadRequest) + + if got := cerrors.Message(err); got != want { + t.Fatalf("message = %q, want %q", got, want) + } + + if _, _, err = list(context.Background(), driver.Page{MaxResults: 25}); err != nil { + t.Fatalf("maxResults=25 rejected: %v", err) + } + }) + } +} + +func TestListOpsRejectNegativeMaxResults(t *testing.T) { + _, ops := listOps(t, 1) + + for name, list := range ops { + t.Run(name, func(t *testing.T) { + _, _, err := list(context.Background(), driver.Page{MaxResults: -1}) + assertException(t, err, driver.ExBadRequest) + }) + } +} + +func TestListOpsDefaultPageIs25(t *testing.T) { + _, ops := listOps(t, 30) + + for name, list := range ops { + t.Run(name, func(t *testing.T) { + n, next, err := list(context.Background(), driver.Page{}) + if err != nil { + t.Fatalf("list: %v", err) + } + + if n != 25 || next == "" { + t.Fatalf("default page len=%d next=%q, want 25 and a token", n, next) + } + + n, next, err = list(context.Background(), driver.Page{NextToken: next}) + if err != nil || n != 5 || next != "" { + t.Fatalf("second page len=%d next=%q err=%v, want 5 and no token", n, next, err) + } + }) + } +} diff --git a/providers/aws/appsync/resource_ids_test.go b/providers/aws/appsync/resource_ids_test.go new file mode 100644 index 000000000..1e3b688d7 --- /dev/null +++ b/providers/aws/appsync/resource_ids_test.go @@ -0,0 +1,57 @@ +package appsync_test + +import ( + "context" + "regexp" + "testing" + + "github.com/stackshy/cloudemu/v2/services/appsync/driver" +) + +func TestAPIKeyIDShape(t *testing.T) { + m := newMock(t) + ctx := context.Background() + api := createAPI(t, m, "my-api") + + re := regexp.MustCompile(`^da2-[a-z0-9]{26}$`) + seen := map[string]bool{} + + for i := 0; i < 3; i++ { + key, err := m.CreateAPIKey(ctx, &driver.CreateAPIKeyInput{APIID: api.APIID}) + if err != nil { + t.Fatalf("CreateAPIKey: %v", err) + } + + if !re.MatchString(key.ID) { + t.Fatalf("api key id %q does not match %s", key.ID, re) + } + + if seen[key.ID] { + t.Fatalf("duplicate api key id %q", key.ID) + } + + seen[key.ID] = true + } +} + +// TestGraphqlAPIARNPassesTagResourceValidation guards the ARN the CLI sends to +// tag-resource. botocore checks ResourceArn against the model's length (70-75) +// and pattern before the request leaves the client. +func TestGraphqlAPIARNPassesTagResourceValidation(t *testing.T) { + m := newMock(t) + ctx := context.Background() + api := createAPI(t, m, "my-api") + + re := regexp.MustCompile(`^arn:aws:appsync:[A-Za-z0-9_/.-]{0,63}:\d{12}:apis/[0-9A-Za-z_-]{26}$`) + if !re.MatchString(api.ARN) { + t.Fatalf("arn %q does not match the ResourceArn pattern", api.ARN) + } + + if n := len(api.ARN); n < 70 || n > 75 { + t.Fatalf("arn %q has length %d, want 70-75", api.ARN, n) + } + + if err := m.TagResource(ctx, api.ARN, map[string]string{"env": "test"}); err != nil { + t.Fatalf("TagResource: %v", err) + } +} diff --git a/providers/aws/appsync/validation.go b/providers/aws/appsync/validation.go new file mode 100644 index 000000000..447d19268 --- /dev/null +++ b/providers/aws/appsync/validation.go @@ -0,0 +1,264 @@ +package appsync + +import ( + "encoding/json" + "strings" + + "github.com/stackshy/cloudemu/v2/services/appsync/driver" +) + +// maxListResults is the maxResults ceiling on every AppSync List operation, per +// the service model. It is also the page size when the caller sends none. +const maxListResults = 25 + +// Request field names for the per-auth-type config blocks. +const ( + fieldUserPoolConfig = "userPoolConfig" + fieldOIDCConfig = "openIDConnectConfig" + fieldLambdaAuthConfig = "lambdaAuthorizerConfig" + fieldAdditionalAuth = "additionalAuthenticationProviders" +) + +// Request field names for the per-type data-source config blocks. +const ( + fieldDynamoDBConfig = "dynamodbConfig" + fieldLambdaConfig = "lambdaConfig" + fieldElasticsearchConfig = "elasticsearchConfig" + fieldOpenSearchConfig = "openSearchServiceConfig" + fieldHTTPConfig = "httpConfig" + fieldRelationalConfig = "relationalDatabaseConfig" + fieldEventBridgeConfig = "eventBridgeConfig" +) + +// errEmptyString is the message AppSync returns when a required string field +// (such as a data source's serviceRoleArn) is missing or empty. +const errEmptyString = "The validated string is empty" + +// validDefaultActions are the accepted userPoolConfig.defaultAction values. +// +//nolint:gochecknoglobals // immutable validation set, read-only after init. +var validDefaultActions = map[string]bool{"ALLOW": true, "DENY": true} + +// authConfigField maps an authentication type to the config block it needs. +// API_KEY and AWS_IAM take none. +// +//nolint:gochecknoglobals // immutable lookup table, read-only after init. +var authConfigField = map[string]string{ + driver.AuthCognito: fieldUserPoolConfig, + driver.AuthOpenIDConnect: fieldOIDCConfig, + driver.AuthLambda: fieldLambdaAuthConfig, +} + +// configBlockName is the name AppSync uses for a config block in its errors. +// +//nolint:gochecknoglobals // immutable lookup table, read-only after init. +var configBlockName = map[string]string{ + fieldUserPoolConfig: "UserPoolConfig", + fieldOIDCConfig: "OpenIDConnectConfig", + fieldLambdaAuthConfig: "LambdaAuthorizerConfig", + fieldDynamoDBConfig: "DynamodbConfig", + fieldLambdaConfig: "LambdaConfig", + fieldElasticsearchConfig: "ElasticsearchConfig", + fieldOpenSearchConfig: "OpenSearchServiceConfig", + fieldHTTPConfig: "HttpConfig", + fieldRelationalConfig: "RelationalDatabaseConfig", + fieldEventBridgeConfig: "EventBridgeConfig", +} + +// dataSourceConfigFields lists the data-source config blocks in a fixed order, +// so a request carrying several wrong blocks always names the same one. +// +//nolint:gochecknoglobals // immutable key list, read-only after init. +var dataSourceConfigFields = []string{ + fieldDynamoDBConfig, fieldLambdaConfig, fieldElasticsearchConfig, fieldOpenSearchConfig, + fieldHTTPConfig, fieldRelationalConfig, fieldEventBridgeConfig, +} + +// dataSourceRule is what a data-source type requires: its config block (empty +// for none), the required string fields inside it, and whether the type needs +// a serviceRoleArn. +type dataSourceRule struct { + config string + required []string + needRole bool +} + +//nolint:gochecknoglobals // immutable lookup table, read-only after init. +var dataSourceRules = map[string]dataSourceRule{ + driver.DataSourceDynamoDB: {config: fieldDynamoDBConfig, required: []string{"tableName", "awsRegion"}, needRole: true}, + driver.DataSourceLambda: {config: fieldLambdaConfig, required: []string{"lambdaFunctionArn"}, needRole: true}, + driver.DataSourceElasticsearch: {config: fieldElasticsearchConfig, required: []string{"endpoint", "awsRegion"}, needRole: true}, + driver.DataSourceOpenSearch: {config: fieldOpenSearchConfig, required: []string{"endpoint", "awsRegion"}, needRole: true}, + driver.DataSourceHTTP: {config: fieldHTTPConfig, required: []string{"endpoint"}}, + driver.DataSourceRelational: {config: fieldRelationalConfig}, + driver.DataSourceEventBridge: {config: fieldEventBridgeConfig, required: []string{"eventBusArn"}, needRole: true}, + driver.DataSourceNone: {}, + driver.DataSourceBedrockRuntime: {}, +} + +// validatePage rejects a maxResults outside [0, 25], with the service's +// constraint-violation wording. +func validatePage(page driver.Page) error { + if page.MaxResults < 0 { + return badRequest("1 validation error detected: Value '%d' at 'maxResults' failed to satisfy constraint: "+ + "Member must have value greater than or equal to 0", page.MaxResults) + } + + if page.MaxResults > maxListResults { + return badRequest("1 validation error detected: Value '%d' at 'maxResults' failed to satisfy constraint: "+ + "Member must have value less than or equal to %d", page.MaxResults, maxListResults) + } + + return nil +} + +// present reports whether a raw field was sent with a non-null value. +func present(extra map[string]json.RawMessage, key string) bool { + v, ok := extra[key] + + return ok && strings.TrimSpace(string(v)) != "null" +} + +// validateAuthConfig checks that the primary authentication type and every +// additional provider carry the config block their type needs. +func validateAuthConfig(authType string, extra map[string]json.RawMessage) error { + if err := checkAuthBlock(authType, extra); err != nil { + return err + } + + if authType == driver.AuthCognito { + var cfg struct { + DefaultAction string `json:"defaultAction"` + } + + if err := json.Unmarshal(extra[fieldUserPoolConfig], &cfg); err != nil || !validDefaultActions[cfg.DefaultAction] { + return badRequest("Invalid default effect type") + } + } + + return validateAdditionalAuth(extra) +} + +// validateAdditionalAuth checks each additionalAuthenticationProviders entry +// names a valid type and carries that type's config block. +func validateAdditionalAuth(extra map[string]json.RawMessage) error { + if !present(extra, fieldAdditionalAuth) { + return nil + } + + var providers []map[string]json.RawMessage + if err := json.Unmarshal(extra[fieldAdditionalAuth], &providers); err != nil { + return badRequest("additionalAuthenticationProviders must be a list") + } + + for _, p := range providers { + var t string + if present(p, "authenticationType") { + _ = json.Unmarshal(p["authenticationType"], &t) + } + + if t == "" { + return badRequest("AuthenticationType can't be null.") + } + + if !validAuthTypes[t] { + return badRequest("authenticationType %q is not valid", t) + } + + if err := checkAuthBlock(t, p); err != nil { + return err + } + } + + return nil +} + +// checkAuthBlock requires the config block an authentication type needs. +func checkAuthBlock(authType string, fields map[string]json.RawMessage) error { + field, ok := authConfigField[authType] + if !ok || present(fields, field) { + return nil + } + + return badRequest("%s can't be null.", configBlockName[field]) +} + +// validateDataSourceConfig checks a data source's type against its config +// blocks and serviceRoleArn: the type's own block is required, a block for +// another type is rejected, and required strings inside the block must be set. +func validateDataSourceConfig(dsType, serviceRoleArn string, extra map[string]json.RawMessage) error { + rule := dataSourceRules[dsType] + + for _, field := range dataSourceConfigFields { + if field != rule.config && present(extra, field) { + return badRequest("%s is not supported for data source type %s.", configBlockName[field], dsType) + } + } + + if rule.config != "" && !present(extra, rule.config) { + return badRequest("%s can't be null.", configBlockName[rule.config]) + } + + if err := checkBlockFields(dsType, rule, extra); err != nil { + return err + } + + if serviceRoleArn == "" && (rule.needRole || httpNeedsRole(dsType, extra)) { + return badRequest(errEmptyString) + } + + return nil +} + +// checkBlockFields requires the non-empty string fields inside a config block. +// RELATIONAL_DATABASE nests its required block one level down. +func checkBlockFields(dsType string, rule dataSourceRule, extra map[string]json.RawMessage) error { + if rule.config == "" { + return nil + } + + var block map[string]json.RawMessage + if err := json.Unmarshal(extra[rule.config], &block); err != nil { + return badRequest("%s must be an object", configBlockName[rule.config]) + } + + if dsType == driver.DataSourceRelational && !present(block, "rdsHttpEndpointConfig") { + return badRequest("RdsHttpEndpointConfig can't be null.") + } + + for _, f := range rule.required { + var s string + if present(block, f) { + _ = json.Unmarshal(block[f], &s) + } + + if s == "" { + return badRequest(errEmptyString) + } + } + + return nil +} + +// httpNeedsRole reports whether an HTTP data source signs its requests with +// IAM. authorizationConfig.authorizationType defaults to AWS_IAM, so any +// authorizationConfig without another type needs a serviceRoleArn. +func httpNeedsRole(dsType string, extra map[string]json.RawMessage) bool { + if dsType != driver.DataSourceHTTP { + return false + } + + var cfg struct { + AuthorizationConfig *struct { + AuthorizationType string `json:"authorizationType"` + } `json:"authorizationConfig"` + } + + if err := json.Unmarshal(extra[fieldHTTPConfig], &cfg); err != nil || cfg.AuthorizationConfig == nil { + return false + } + + t := cfg.AuthorizationConfig.AuthorizationType + + return t == "" || t == driver.AuthAWSIAM +} diff --git a/server/aws/appsync/handler.go b/server/aws/appsync/handler.go index 86ee7f6b4..04d284a8d 100644 --- a/server/aws/appsync/handler.go +++ b/server/aws/appsync/handler.go @@ -13,6 +13,7 @@ package appsync import ( + "math" "net/http" "net/url" "strings" @@ -195,6 +196,12 @@ func atoiDefault(s string, def int32) int32 { } n = n*10 + int(c-'0') + + // Saturate so an oversized value still fails the maxResults cap + // instead of wrapping into range. + if n > math.MaxInt32 { + return math.MaxInt32 + } } return int32(n) diff --git a/server/aws/appsync/validation_wire_test.go b/server/aws/appsync/validation_wire_test.go new file mode 100644 index 000000000..c12909678 --- /dev/null +++ b/server/aws/appsync/validation_wire_test.go @@ -0,0 +1,113 @@ +package appsync_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsappsync "github.com/aws/aws-sdk-go-v2/service/appsync" + astypes "github.com/aws/aws-sdk-go-v2/service/appsync/types" + + "github.com/stackshy/cloudemu/v2" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +func assertSDKBadRequest(t *testing.T, err error, wantMsg string) { + t.Helper() + + var bre *astypes.BadRequestException + if !errors.As(err, &bre) { + t.Fatalf("want BadRequestException, got %v", err) + } + + if got := aws.ToString(bre.Message); got != wantMsg { + t.Fatalf("message = %q, want %q", got, wantMsg) + } +} + +func TestSDKListMaxResultsCap(t *testing.T) { + ctx := context.Background() + c := newClient(t) + apiID := mustCreateAPI(t, c) + + const want = "1 validation error detected: Value '26' at 'maxResults' failed to satisfy constraint: " + + "Member must have value less than or equal to 25" + + _, err := c.ListGraphqlApis(ctx, &awsappsync.ListGraphqlApisInput{MaxResults: 26}) + assertSDKBadRequest(t, err, want) + + _, err = c.ListDataSources(ctx, &awsappsync.ListDataSourcesInput{ApiId: aws.String(apiID), MaxResults: 26}) + assertSDKBadRequest(t, err, want) + + _, err = c.ListApiKeys(ctx, &awsappsync.ListApiKeysInput{ApiId: aws.String(apiID), MaxResults: 26}) + assertSDKBadRequest(t, err, want) + + if _, err = c.ListGraphqlApis(ctx, &awsappsync.ListGraphqlApisInput{MaxResults: 25}); err != nil { + t.Fatalf("maxResults=25 rejected: %v", err) + } +} + +// TestWireOversizedMaxResultsRejected checks a maxResults too big for int32 +// is still rejected rather than wrapping into range. +func TestWireOversizedMaxResultsRejected(t *testing.T) { + cloud := cloudemu.NewAWS() + ts := httptest.NewServer(awsserver.New(awsserver.Drivers{AppSync: cloud.AppSync})) + t.Cleanup(ts.Close) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/v1/apis?maxResults=4294967297", http.NoBody) + if err != nil { + t.Fatalf("new request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest || resp.Header.Get("X-Amzn-Errortype") != "BadRequestException" { + t.Fatalf("status=%d errortype=%q, want 400 BadRequestException", resp.StatusCode, resp.Header.Get("X-Amzn-Errortype")) + } +} + +func TestSDKConfigCrossValidation(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + _, err := c.CreateGraphqlApi(ctx, &awsappsync.CreateGraphqlApiInput{ + Name: aws.String("cognito"), AuthenticationType: astypes.AuthenticationTypeAmazonCognitoUserPools, + }) + assertSDKBadRequest(t, err, "UserPoolConfig can't be null.") + + _, err = c.CreateGraphqlApi(ctx, &awsappsync.CreateGraphqlApiInput{ + Name: aws.String("extra"), AuthenticationType: astypes.AuthenticationTypeApiKey, + AdditionalAuthenticationProviders: []astypes.AdditionalAuthenticationProvider{ + {AuthenticationType: astypes.AuthenticationTypeAwsLambda}, + }, + }) + assertSDKBadRequest(t, err, "LambdaAuthorizerConfig can't be null.") + + apiID := mustCreateAPI(t, c) + + _, err = c.CreateDataSource(ctx, &awsappsync.CreateDataSourceInput{ + ApiId: aws.String(apiID), Name: aws.String("ddb"), Type: astypes.DataSourceTypeAmazonDynamodb, + ServiceRoleArn: aws.String("arn:aws:iam::123456789012:role/r"), + }) + assertSDKBadRequest(t, err, "DynamodbConfig can't be null.") + + out, err := c.CreateDataSource(ctx, &awsappsync.CreateDataSourceInput{ + ApiId: aws.String(apiID), Name: aws.String("ddb"), Type: astypes.DataSourceTypeAmazonDynamodb, + ServiceRoleArn: aws.String("arn:aws:iam::123456789012:role/r"), + DynamodbConfig: &astypes.DynamodbDataSourceConfig{TableName: aws.String("t"), AwsRegion: aws.String("us-east-1")}, + }) + if err != nil { + t.Fatalf("CreateDataSource with config: %v", err) + } + + if out.DataSource.DynamodbConfig == nil || aws.ToString(out.DataSource.DynamodbConfig.TableName) != "t" { + t.Fatalf("dynamodbConfig not round-tripped: %#v", out.DataSource.DynamodbConfig) + } +} diff --git a/services/appsync/driver/driver.go b/services/appsync/driver/driver.go index e1624c36e..c7fa607a0 100644 --- a/services/appsync/driver/driver.go +++ b/services/appsync/driver/driver.go @@ -46,14 +46,15 @@ const ( // Data-source types accepted by CreateDataSource / UpdateDataSource. const ( - DataSourceLambda = "AWS_LAMBDA" - DataSourceDynamoDB = "AMAZON_DYNAMODB" - DataSourceElasticsearch = "AMAZON_ELASTICSEARCH" - DataSourceOpenSearch = "AMAZON_OPENSEARCH_SERVICE" - DataSourceHTTP = "HTTP" - DataSourceNone = "NONE" - DataSourceRelational = "RELATIONAL_DATABASE" - DataSourceEventBridge = "AMAZON_EVENTBRIDGE" + DataSourceLambda = "AWS_LAMBDA" + DataSourceDynamoDB = "AMAZON_DYNAMODB" + DataSourceElasticsearch = "AMAZON_ELASTICSEARCH" + DataSourceOpenSearch = "AMAZON_OPENSEARCH_SERVICE" + DataSourceHTTP = "HTTP" + DataSourceNone = "NONE" + DataSourceRelational = "RELATIONAL_DATABASE" + DataSourceEventBridge = "AMAZON_EVENTBRIDGE" + DataSourceBedrockRuntime = "AMAZON_BEDROCK_RUNTIME" ) // GraphqlAPI is an AppSync GraphQL API. apiId, arn, uris, and owner are From 848a0c38fa4e9398428c135133a7be45ffacbd64 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sun, 27 Sep 2026 17:27:42 +0530 Subject: [PATCH 2/2] fix(aws-appsync): keep omitted config blocks on update, bound Lambda authorizers --- providers/aws/appsync/datasource.go | 15 ++- providers/aws/appsync/graphqlapi.go | 13 ++- .../aws/appsync/lambda_authorizer_test.go | 54 ++++++++++ providers/aws/appsync/partial_update_test.go | 83 ++++++++++++++ providers/aws/appsync/validation.go | 101 ++++++++++++++---- server/aws/appsync/validation_wire_test.go | 38 +++++++ 6 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 providers/aws/appsync/lambda_authorizer_test.go create mode 100644 providers/aws/appsync/partial_update_test.go diff --git a/providers/aws/appsync/datasource.go b/providers/aws/appsync/datasource.go index 20c0572c5..2f954e624 100644 --- a/providers/aws/appsync/datasource.go +++ b/providers/aws/appsync/datasource.go @@ -69,8 +69,9 @@ func (m *Mock) GetDataSource(_ context.Context, apiID, name string) (*driver.Dat // UpdateDataSource replaces the mutable fields of a data source while keeping // its computed dataSourceArn. func (m *Mock) UpdateDataSource(_ context.Context, in *driver.UpdateDataSourceInput) (*driver.DataSource, error) { - if err := checkDataSource(in.Type, in.ServiceRoleArn, in.Extra); err != nil { - return nil, err + rule, ok := dataSourceRules[in.Type] + if !ok { + return nil, badRequest("data source type %q is not valid", in.Type) } ad, err := m.getAPI(in.APIID) @@ -86,10 +87,18 @@ func (m *Mock) UpdateDataSource(_ context.Context, in *driver.UpdateDataSourceIn return nil, notFound("data source %q not found", in.Name) } + // Terraform omits some config blocks (eventBridgeConfig) on update, so an + // omitted block for the type keeps the stored one. + extra := carryBlock(in.Extra, ds.Extra, rule.config) + + if err := validateDataSourceConfig(in.Type, in.ServiceRoleArn, extra); err != nil { + return nil, err + } + ds.Type = in.Type ds.Description = in.Description ds.ServiceRoleArn = in.ServiceRoleArn - ds.Extra = copyExtra(in.Extra) + ds.Extra = extra ad.dataSrcs[in.Name] = ds out := copyDataSource(&ds) diff --git a/providers/aws/appsync/graphqlapi.go b/providers/aws/appsync/graphqlapi.go index 4a0c0c833..858539976 100644 --- a/providers/aws/appsync/graphqlapi.go +++ b/providers/aws/appsync/graphqlapi.go @@ -83,10 +83,6 @@ func (m *Mock) UpdateGraphqlAPI(_ context.Context, in *driver.UpdateGraphqlAPIIn return nil, badRequest("authenticationType %q is not valid", in.AuthenticationType) } - if err := validateAuthConfig(in.AuthenticationType, in.Extra); err != nil { - return nil, err - } - ad, err := m.getAPI(in.APIID) if err != nil { return nil, err @@ -95,9 +91,16 @@ func (m *Mock) UpdateGraphqlAPI(_ context.Context, in *driver.UpdateGraphqlAPIIn ad.mu.Lock() defer ad.mu.Unlock() + // An omitted auth config block for the primary type keeps the stored one. + extra := carryBlock(in.Extra, ad.api.Extra, authConfigField[in.AuthenticationType]) + + if err := validateAuthConfig(in.AuthenticationType, extra); err != nil { + return nil, err + } + ad.api.Name = in.Name ad.api.AuthenticationType = in.AuthenticationType - ad.api.Extra = copyExtra(in.Extra) + ad.api.Extra = extra if in.XrayEnabled != nil { ad.api.XrayEnabled = *in.XrayEnabled diff --git a/providers/aws/appsync/lambda_authorizer_test.go b/providers/aws/appsync/lambda_authorizer_test.go new file mode 100644 index 000000000..972238a8e --- /dev/null +++ b/providers/aws/appsync/lambda_authorizer_test.go @@ -0,0 +1,54 @@ +package appsync_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/services/appsync/driver" +) + +func TestLambdaAuthorizerRules(t *testing.T) { + oneLambda := `{"authenticationType":"AWS_LAMBDA","lambdaAuthorizerConfig":` + lambdaAuth + `}` + + cases := []struct { + name string + auth string + extra map[string]json.RawMessage + wantErr string + }{ + {"ttl 3600 ok", driver.AuthLambda, + raw("lambdaAuthorizerConfig", `{"authorizerUri":"u","authorizerResultTtlInSeconds":3600}`), ""}, + {"ttl over 3600", driver.AuthLambda, + raw("lambdaAuthorizerConfig", `{"authorizerUri":"u","authorizerResultTtlInSeconds":3601}`), + "1 validation error detected: Value '3601' at 'lambdaAuthorizerConfig.authorizerResultTtlInSeconds' " + + "failed to satisfy constraint: Member must have value between 0 and 3600"}, + {"ttl negative in additional provider", driver.AuthAPIKey, + raw("additionalAuthenticationProviders", + `[{"authenticationType":"AWS_LAMBDA","lambdaAuthorizerConfig":{"authorizerUri":"u","authorizerResultTtlInSeconds":-1}}]`), + "1 validation error detected: Value '-1' at 'lambdaAuthorizerConfig.authorizerResultTtlInSeconds' " + + "failed to satisfy constraint: Member must have value between 0 and 3600"}, + {"one additional lambda ok", driver.AuthAPIKey, raw("additionalAuthenticationProviders", `[`+oneLambda+`]`), ""}, + {"primary and additional lambda", driver.AuthLambda, + raw("lambdaAuthorizerConfig", lambdaAuth, "additionalAuthenticationProviders", `[`+oneLambda+`]`), + "Only one AWS_LAMBDA authorization type is allowed per API."}, + {"two additional lambdas", driver.AuthAPIKey, + raw("additionalAuthenticationProviders", `[`+oneLambda+`,`+oneLambda+`]`), + "Only one AWS_LAMBDA authorization type is allowed per API."}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + + _, err := m.CreateGraphqlAPI(context.Background(), &driver.CreateGraphqlAPIInput{ + Name: "api", AuthenticationType: tc.auth, Extra: tc.extra, + }) + if tc.wantErr != "" { + assertBadRequest(t, err, tc.wantErr) + } else if err != nil { + t.Fatalf("CreateGraphqlAPI: %v", err) + } + }) + } +} diff --git a/providers/aws/appsync/partial_update_test.go b/providers/aws/appsync/partial_update_test.go new file mode 100644 index 000000000..dc443b9dc --- /dev/null +++ b/providers/aws/appsync/partial_update_test.go @@ -0,0 +1,83 @@ +package appsync_test + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/services/appsync/driver" +) + +// TestUpdateDataSourceKeepsOmittedBlock mirrors terraform-provider-aws, which +// never sends eventBridgeConfig on UpdateDataSource. +func TestUpdateDataSourceKeepsOmittedBlock(t *testing.T) { + const bus = `{"eventBusArn":"arn:aws:events:us-east-1:123456789012:event-bus/default"}` + + m := newMock(t) + ctx := context.Background() + api := createAPI(t, m, "api") + + if _, err := m.CreateDataSource(ctx, &driver.CreateDataSourceInput{ + APIID: api.APIID, Name: "bus", Type: driver.DataSourceEventBridge, ServiceRoleArn: roleARN, + Extra: raw("eventBridgeConfig", bus), + }); err != nil { + t.Fatalf("CreateDataSource: %v", err) + } + + upd, err := m.UpdateDataSource(ctx, &driver.UpdateDataSourceInput{ + APIID: api.APIID, Name: "bus", Type: driver.DataSourceEventBridge, ServiceRoleArn: roleARN, Description: "changed", + }) + if err != nil { + t.Fatalf("UpdateDataSource without eventBridgeConfig: %v", err) + } + + if upd.Description != "changed" || string(upd.Extra["eventBridgeConfig"]) != bus { + t.Fatalf("stored block not kept: %#v", upd) + } + + got, err := m.GetDataSource(ctx, api.APIID, "bus") + if err != nil || string(got.Extra["eventBridgeConfig"]) != bus { + t.Fatalf("GetDataSource lost the block: %v %#v", err, got) + } + + // A block for another type is still rejected. + _, err = m.UpdateDataSource(ctx, &driver.UpdateDataSourceInput{ + APIID: api.APIID, Name: "bus", Type: driver.DataSourceEventBridge, ServiceRoleArn: roleARN, + Extra: raw("httpConfig", `{"endpoint":"https://example.com"}`), + }) + assertBadRequest(t, err, "HttpConfig is not supported for data source type AMAZON_EVENTBRIDGE.") + + // Changing type with no stored block for the new type still needs one. + _, err = m.UpdateDataSource(ctx, &driver.UpdateDataSourceInput{ + APIID: api.APIID, Name: "bus", Type: driver.DataSourceDynamoDB, ServiceRoleArn: roleARN, + }) + assertBadRequest(t, err, "DynamodbConfig can't be null.") +} + +func TestUpdateGraphqlAPIKeepsOmittedAuthBlock(t *testing.T) { + m := newMock(t) + ctx := context.Background() + + api, err := m.CreateGraphqlAPI(ctx, &driver.CreateGraphqlAPIInput{ + Name: "api", AuthenticationType: driver.AuthCognito, Extra: raw("userPoolConfig", userPoolJSON), + }) + if err != nil { + t.Fatalf("CreateGraphqlAPI: %v", err) + } + + upd, err := m.UpdateGraphqlAPI(ctx, &driver.UpdateGraphqlAPIInput{ + APIID: api.APIID, Name: "renamed", AuthenticationType: driver.AuthCognito, + }) + if err != nil { + t.Fatalf("UpdateGraphqlAPI without userPoolConfig: %v", err) + } + + if string(upd.Extra["userPoolConfig"]) != userPoolJSON { + t.Fatalf("userPoolConfig not kept: %#v", upd.Extra) + } + + // Switching to a type whose block was never stored still needs it. + _, err = m.UpdateGraphqlAPI(ctx, &driver.UpdateGraphqlAPIInput{ + APIID: api.APIID, Name: "renamed", AuthenticationType: driver.AuthOpenIDConnect, + }) + assertBadRequest(t, err, "OpenIDConnectConfig can't be null.") +} diff --git a/providers/aws/appsync/validation.go b/providers/aws/appsync/validation.go index 447d19268..471f5132c 100644 --- a/providers/aws/appsync/validation.go +++ b/providers/aws/appsync/validation.go @@ -11,6 +11,9 @@ import ( // the service model. It is also the page size when the caller sends none. const maxListResults = 25 +// maxAuthorizerTTL is the ceiling on lambdaAuthorizerConfig.authorizerResultTtlInSeconds. +const maxAuthorizerTTL = 3600 + // Request field names for the per-auth-type config blocks. const ( fieldUserPoolConfig = "userPoolConfig" @@ -119,10 +122,11 @@ func present(extra map[string]json.RawMessage, key string) bool { return ok && strings.TrimSpace(string(v)) != "null" } -// validateAuthConfig checks that the primary authentication type and every -// additional provider carry the config block their type needs. +// validateAuthConfig checks the primary authentication type and every +// additional provider: each carries the config block its type needs, and an +// API has at most one AWS_LAMBDA authorizer. func validateAuthConfig(authType string, extra map[string]json.RawMessage) error { - if err := checkAuthBlock(authType, extra); err != nil { + if err := checkProvider(authType, extra); err != nil { return err } @@ -136,21 +140,40 @@ func validateAuthConfig(authType string, extra map[string]json.RawMessage) error } } - return validateAdditionalAuth(extra) + types, err := additionalAuthTypes(extra) + if err != nil { + return err + } + + lambdas := 0 + + for _, t := range append(types, authType) { + if t == driver.AuthLambda { + lambdas++ + } + } + + if lambdas > 1 { + return badRequest("Only one AWS_LAMBDA authorization type is allowed per API.") + } + + return nil } -// validateAdditionalAuth checks each additionalAuthenticationProviders entry -// names a valid type and carries that type's config block. -func validateAdditionalAuth(extra map[string]json.RawMessage) error { +// additionalAuthTypes checks each additionalAuthenticationProviders entry and +// returns their authentication types. +func additionalAuthTypes(extra map[string]json.RawMessage) ([]string, error) { if !present(extra, fieldAdditionalAuth) { - return nil + return nil, nil } var providers []map[string]json.RawMessage if err := json.Unmarshal(extra[fieldAdditionalAuth], &providers); err != nil { - return badRequest("additionalAuthenticationProviders must be a list") + return nil, badRequest("additionalAuthenticationProviders must be a list") } + types := make([]string, 0, len(providers)) + for _, p := range providers { var t string if present(p, "authenticationType") { @@ -158,29 +181,71 @@ func validateAdditionalAuth(extra map[string]json.RawMessage) error { } if t == "" { - return badRequest("AuthenticationType can't be null.") + return nil, badRequest("AuthenticationType can't be null.") } if !validAuthTypes[t] { - return badRequest("authenticationType %q is not valid", t) + return nil, badRequest("authenticationType %q is not valid", t) } - if err := checkAuthBlock(t, p); err != nil { - return err + if err := checkProvider(t, p); err != nil { + return nil, err } + + types = append(types, t) } - return nil + return types, nil } -// checkAuthBlock requires the config block an authentication type needs. -func checkAuthBlock(authType string, fields map[string]json.RawMessage) error { +// checkProvider requires the config block an authentication type needs and, +// for AWS_LAMBDA, bounds authorizerResultTtlInSeconds to [0, 3600]. +func checkProvider(authType string, fields map[string]json.RawMessage) error { field, ok := authConfigField[authType] - if !ok || present(fields, field) { + if !ok { return nil } - return badRequest("%s can't be null.", configBlockName[field]) + if !present(fields, field) { + return badRequest("%s can't be null.", configBlockName[field]) + } + + if authType != driver.AuthLambda { + return nil + } + + var cfg struct { + TTL *int64 `json:"authorizerResultTtlInSeconds"` + } + + if err := json.Unmarshal(fields[field], &cfg); err != nil { + return badRequest("%s must be an object", configBlockName[field]) + } + + if cfg.TTL != nil && (*cfg.TTL < 0 || *cfg.TTL > maxAuthorizerTTL) { + return badRequest("1 validation error detected: Value '%d' at 'lambdaAuthorizerConfig.authorizerResultTtlInSeconds' "+ + "failed to satisfy constraint: Member must have value between 0 and %d", *cfg.TTL, maxAuthorizerTTL) + } + + return nil +} + +// carryBlock returns a copy of the update's fields. When the update omits the +// config block field, the stored block is kept, so a partial update (as +// Terraform sends for eventBridgeConfig) neither fails nor drops it. +func carryBlock(update, stored map[string]json.RawMessage, field string) map[string]json.RawMessage { + out := copyExtra(update) + if field == "" || present(out, field) || !present(stored, field) { + return out + } + + if out == nil { + out = map[string]json.RawMessage{} + } + + out[field] = append(json.RawMessage(nil), stored[field]...) + + return out } // validateDataSourceConfig checks a data source's type against its config diff --git a/server/aws/appsync/validation_wire_test.go b/server/aws/appsync/validation_wire_test.go index c12909678..132e277c8 100644 --- a/server/aws/appsync/validation_wire_test.go +++ b/server/aws/appsync/validation_wire_test.go @@ -111,3 +111,41 @@ func TestSDKConfigCrossValidation(t *testing.T) { t.Fatalf("dynamodbConfig not round-tripped: %#v", out.DataSource.DynamodbConfig) } } + +// TestSDKUpdateEventBridgeDataSourceWithoutConfig sends the UpdateDataSource +// shape terraform-provider-aws uses, which never includes eventBridgeConfig. +func TestSDKUpdateEventBridgeDataSourceWithoutConfig(t *testing.T) { + ctx := context.Background() + c := newClient(t) + apiID := mustCreateAPI(t, c) + + const bus = "arn:aws:events:us-east-1:123456789012:event-bus/default" + + role := aws.String("arn:aws:iam::123456789012:role/r") + + if _, err := c.CreateDataSource(ctx, &awsappsync.CreateDataSourceInput{ + ApiId: aws.String(apiID), Name: aws.String("bus"), Type: astypes.DataSourceTypeAmazonEventbridge, + ServiceRoleArn: role, EventBridgeConfig: &astypes.EventBridgeDataSourceConfig{EventBusArn: aws.String(bus)}, + }); err != nil { + t.Fatalf("CreateDataSource: %v", err) + } + + upd, err := c.UpdateDataSource(ctx, &awsappsync.UpdateDataSourceInput{ + ApiId: aws.String(apiID), Name: aws.String("bus"), Type: astypes.DataSourceTypeAmazonEventbridge, + ServiceRoleArn: role, Description: aws.String("changed"), + }) + if err != nil { + t.Fatalf("UpdateDataSource: %v", err) + } + + ds := upd.DataSource + if aws.ToString(ds.Description) != "changed" || ds.EventBridgeConfig == nil || aws.ToString(ds.EventBridgeConfig.EventBusArn) != bus { + t.Fatalf("update lost eventBridgeConfig or description: %#v", ds) + } + + _, err = c.UpdateDataSource(ctx, &awsappsync.UpdateDataSourceInput{ + ApiId: aws.String(apiID), Name: aws.String("bus"), Type: astypes.DataSourceTypeAmazonEventbridge, + ServiceRoleArn: role, HttpConfig: &astypes.HttpDataSourceConfig{Endpoint: aws.String("https://example.com")}, + }) + assertSDKBadRequest(t, err, "HttpConfig is not supported for data source type AMAZON_EVENTBRIDGE.") +}