Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion providers/aws/appsync/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions providers/aws/appsync/appsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions providers/aws/appsync/appsync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
207 changes: 207 additions & 0 deletions providers/aws/appsync/config_validation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
43 changes: 27 additions & 16 deletions providers/aws/appsync/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,21 @@ 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) {
if in.Name == "" {
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)
Expand Down Expand Up @@ -80,7 +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] {
rule, ok := dataSourceRules[in.Type]
if !ok {
return nil, badRequest("data source type %q is not valid", in.Type)
}

Expand All @@ -97,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)

Expand Down Expand Up @@ -129,6 +127,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
Expand All @@ -143,6 +145,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 {
Expand Down
Loading
Loading