Skip to content

Commit c1ab55f

Browse files
committed
test: add 5 more CFN synthesis fixtures for previously-uncovered code paths
Builds on the 24-fixture suite from #704. Each new fixture targets a specific plugin code path that the existing fixtures don't exercise: - sync-config-versioned (3 tests): DynamoDB conflict resolution with OPTIMISTIC_CONCURRENCY and AUTOMERGE handlers, plus delta sync table configuration for offline-capable mobile apps. Closes a gap where syncConfig.test.ts had unit coverage but no synthesis coverage. - custom-domain-no-cfn (5 tests): The useCloudFormation: false code path on the domain config. Asserts the absence of CFN resources (DomainName, DomainNameApiAssociation, RecordSet, Certificate) that the useCloudFormation: true variant DOES create. Complements the existing custom-domain fixture. - waf-pre-existing-arn (2 tests): WAF attached via an existing ACL ARN rather than created inline via rules. Different code path in Waf.ts that emits only WebACLAssociation, not WebACL. Complements the existing waf fixture. - pipeline-resolver-with-code (3 tests): A pipeline resolver with its own top-level JS code (before/after handlers) in addition to per- function code. Different from the existing pipeline-resolvers fixture which only has function-level code. - api-key-import-existing (3 tests): Importing an existing API key via apiKeyId for stable migrations / blue-green deploys, alongside an auto-generated key. Verifies ApiKeyId is passed through faithfully. Test surface grows from 25 -> 30 suites and 68 -> 84 assertions. Real plugin behavior caught while building the fixtures: - DataSource config 'versioned: true' is silently ignored unless 'deltaSyncConfig' is ALSO provided. Without delta sync config, the CFN output omits the Versioned attribute entirely. Worth noting in docs eventually, but not breaking — included both in this fixture to demonstrate the combination that actually works.
1 parent bda9abe commit c1ab55f

19 files changed

Lines changed: 668 additions & 27 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import {
3+
countResourcesByType,
4+
findResourcesByType,
5+
} from './helpers/assertions';
6+
7+
describe('examples/api-key-import-existing', () => {
8+
let result: ReturnType<typeof synthesize>;
9+
10+
beforeAll(() => {
11+
result = synthesize('examples/api-key-import-existing');
12+
});
13+
14+
afterAll(() => {
15+
result.cleanup();
16+
});
17+
18+
it('creates two API key resources', () => {
19+
expect(countResourcesByType(result.template, 'AWS::AppSync::ApiKey')).toBe(
20+
2,
21+
);
22+
});
23+
24+
it('passes the apiKeyId through to ApiKeyId on the stable key', () => {
25+
const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey');
26+
const stable = keys.find(
27+
(k) =>
28+
(k.resource.Properties?.Description as string) ===
29+
'Stable key migrated from previous infrastructure',
30+
);
31+
if (!stable) throw new Error('stable api key not found');
32+
expect(stable.resource.Properties?.ApiKeyId).toBe(
33+
'da2-rotated-stable-key-id-abc123',
34+
);
35+
});
36+
37+
it('does NOT set ApiKeyId on the rotating key (lets AppSync generate one)', () => {
38+
const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey');
39+
const rotating = keys.find(
40+
(k) =>
41+
(k.resource.Properties?.Description as string) ===
42+
'Net-new key created by this stack',
43+
);
44+
if (!rotating) throw new Error('rotating api key not found');
45+
expect(rotating.resource.Properties?.ApiKeyId).toBeUndefined();
46+
});
47+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import { countResourcesByType, getGraphQlApi } from './helpers/assertions';
3+
4+
describe('examples/custom-domain-no-cfn', () => {
5+
let result: ReturnType<typeof synthesize>;
6+
7+
beforeAll(() => {
8+
result = synthesize('examples/custom-domain-no-cfn');
9+
});
10+
11+
afterAll(() => {
12+
result.cleanup();
13+
});
14+
15+
it('still creates the GraphQLApi (domain is independent of API)', () => {
16+
const { resource } = getGraphQlApi(result.template);
17+
expect(resource.Properties?.Name).toBe('custom-domain-no-cfn');
18+
});
19+
20+
it('does NOT create an AWS::AppSync::DomainName resource', () => {
21+
expect(
22+
countResourcesByType(result.template, 'AWS::AppSync::DomainName'),
23+
).toBe(0);
24+
});
25+
26+
it('does NOT create an AWS::AppSync::DomainNameApiAssociation resource', () => {
27+
expect(
28+
countResourcesByType(
29+
result.template,
30+
'AWS::AppSync::DomainNameApiAssociation',
31+
),
32+
).toBe(0);
33+
});
34+
35+
it('does NOT create an AWS::Route53::RecordSet resource', () => {
36+
expect(
37+
countResourcesByType(result.template, 'AWS::Route53::RecordSet'),
38+
).toBe(0);
39+
});
40+
41+
it('does NOT create an AWS::CertificateManager::Certificate resource', () => {
42+
expect(
43+
countResourcesByType(
44+
result.template,
45+
'AWS::CertificateManager::Certificate',
46+
),
47+
).toBe(0);
48+
});
49+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import {
3+
countResourcesByType,
4+
findResourcesByType,
5+
} from './helpers/assertions';
6+
7+
describe('examples/pipeline-resolver-with-code', () => {
8+
let result: ReturnType<typeof synthesize>;
9+
10+
beforeAll(() => {
11+
result = synthesize('examples/pipeline-resolver-with-code');
12+
});
13+
14+
afterAll(() => {
15+
result.cleanup();
16+
});
17+
18+
it('creates one pipeline resolver and two pipeline functions', () => {
19+
expect(
20+
countResourcesByType(result.template, 'AWS::AppSync::Resolver'),
21+
).toBe(1);
22+
expect(
23+
countResourcesByType(
24+
result.template,
25+
'AWS::AppSync::FunctionConfiguration',
26+
),
27+
).toBe(2);
28+
});
29+
30+
it('the pipeline resolver has both its own Code AND a Runtime', () => {
31+
const resolvers = findResourcesByType(
32+
result.template,
33+
'AWS::AppSync::Resolver',
34+
);
35+
expect(resolvers).toHaveLength(1);
36+
const props = resolvers[0].resource.Properties as Record<string, unknown>;
37+
expect(props.Kind).toBe('PIPELINE');
38+
// This is the key assertion: the resolver itself has Code (the
39+
// before/after JS), not just delegating to its functions.
40+
expect(props.Code).toBeDefined();
41+
expect(props.Runtime).toEqual({
42+
Name: 'APPSYNC_JS',
43+
RuntimeVersion: '1.0.0',
44+
});
45+
// And the PipelineConfig references both functions
46+
const pipelineConfig = props.PipelineConfig as { Functions: unknown[] };
47+
expect(pipelineConfig.Functions).toHaveLength(2);
48+
});
49+
50+
it('each pipeline function also has its own Code + Runtime', () => {
51+
const functions = findResourcesByType(
52+
result.template,
53+
'AWS::AppSync::FunctionConfiguration',
54+
);
55+
expect(functions).toHaveLength(2);
56+
functions.forEach((fn) => {
57+
const props = fn.resource.Properties as Record<string, unknown>;
58+
expect(props.Code).toBeDefined();
59+
expect(props.Runtime).toEqual({
60+
Name: 'APPSYNC_JS',
61+
RuntimeVersion: '1.0.0',
62+
});
63+
});
64+
});
65+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import { findResourcesByType } from './helpers/assertions';
3+
4+
describe('examples/sync-config-versioned', () => {
5+
let result: ReturnType<typeof synthesize>;
6+
7+
beforeAll(() => {
8+
result = synthesize('examples/sync-config-versioned');
9+
});
10+
11+
afterAll(() => {
12+
result.cleanup();
13+
});
14+
15+
it('emits SyncConfig with OPTIMISTIC_CONCURRENCY on updatePost resolver', () => {
16+
const resolvers = findResourcesByType(
17+
result.template,
18+
'AWS::AppSync::Resolver',
19+
);
20+
const updatePost = resolvers.find(
21+
(r) =>
22+
r.resource.Properties?.TypeName === 'Mutation' &&
23+
r.resource.Properties?.FieldName === 'updatePost',
24+
);
25+
if (!updatePost) throw new Error('Mutation.updatePost resolver not found');
26+
const sync = updatePost.resource.Properties?.SyncConfig as Record<
27+
string,
28+
unknown
29+
>;
30+
expect(sync).toBeDefined();
31+
expect(sync.ConflictDetection).toBe('VERSION');
32+
expect(sync.ConflictHandler).toBe('OPTIMISTIC_CONCURRENCY');
33+
});
34+
35+
it('emits SyncConfig with AUTOMERGE on mergePost resolver', () => {
36+
const resolvers = findResourcesByType(
37+
result.template,
38+
'AWS::AppSync::Resolver',
39+
);
40+
const mergePost = resolvers.find(
41+
(r) =>
42+
r.resource.Properties?.TypeName === 'Mutation' &&
43+
r.resource.Properties?.FieldName === 'mergePost',
44+
);
45+
if (!mergePost) throw new Error('Mutation.mergePost resolver not found');
46+
const sync = mergePost.resource.Properties?.SyncConfig as Record<
47+
string,
48+
unknown
49+
>;
50+
expect(sync).toBeDefined();
51+
expect(sync.ConflictDetection).toBe('VERSION');
52+
expect(sync.ConflictHandler).toBe('AUTOMERGE');
53+
});
54+
55+
it('marks the DynamoDB data source as versioned with delta sync config', () => {
56+
const dataSources = findResourcesByType(
57+
result.template,
58+
'AWS::AppSync::DataSource',
59+
);
60+
const posts = dataSources.find(
61+
(ds) => ds.resource.Properties?.Name === 'posts',
62+
);
63+
if (!posts) throw new Error('posts data source not found');
64+
const ddbConfig = posts.resource.Properties?.DynamoDBConfig as Record<
65+
string,
66+
unknown
67+
>;
68+
expect(ddbConfig).toBeDefined();
69+
// The plugin only emits Versioned: true when deltaSyncConfig is ALSO
70+
// provided — this fixture covers both together.
71+
expect(ddbConfig.Versioned).toBe(true);
72+
const delta = ddbConfig.DeltaSyncConfig as Record<string, unknown>;
73+
expect(delta).toBeDefined();
74+
expect(delta.BaseTableTTL).toBe(43200);
75+
expect(delta.DeltaSyncTableTTL).toBe(1440);
76+
expect(delta.DeltaSyncTableName).toBeDefined();
77+
});
78+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { synthesize } from './helpers/synthesize';
2+
import {
3+
countResourcesByType,
4+
findOneResourceByType,
5+
} from './helpers/assertions';
6+
7+
describe('examples/waf-pre-existing-arn', () => {
8+
let result: ReturnType<typeof synthesize>;
9+
10+
beforeAll(() => {
11+
result = synthesize('examples/waf-pre-existing-arn');
12+
});
13+
14+
afterAll(() => {
15+
result.cleanup();
16+
});
17+
18+
it('does NOT create an AWS::WAFv2::WebACL resource (uses pre-existing)', () => {
19+
expect(countResourcesByType(result.template, 'AWS::WAFv2::WebACL')).toBe(0);
20+
});
21+
22+
it('creates an AWS::WAFv2::WebACLAssociation pointing at the imported ARN', () => {
23+
const { resource } = findOneResourceByType(
24+
result.template,
25+
'AWS::WAFv2::WebACLAssociation',
26+
);
27+
const props = resource.Properties as Record<string, unknown>;
28+
expect(props.ResourceArn).toBeDefined();
29+
30+
// WebACLArn is the user-provided intrinsic, faithfully preserved
31+
const webAclArn = props.WebACLArn as Record<string, unknown>;
32+
expect(webAclArn['Fn::ImportValue']).toBe('SharedWafAclArn');
33+
});
34+
});

examples/README.md

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,38 @@ stay current with the plugin's actual behavior — if they break, CI fails.
1414

1515
## Index
1616

17-
| Example | What it shows |
18-
| --------------------------------------------------- | ------------------------------------------------------------------------------ |
19-
| [basic-api-key](./basic-api-key/) | Simplest possible setup: API key auth, one DynamoDB data source, one resolver |
20-
| [cognito-userpools](./cognito-userpools/) | Cognito User Pools authentication with default action and user groups |
21-
| [iam-auth](./iam-auth/) | AWS IAM authentication |
22-
| [oidc-auth](./oidc-auth/) | OpenID Connect authentication |
23-
| [lambda-authorizer](./lambda-authorizer/) | Custom AWS Lambda authorizer |
24-
| [multi-auth](./multi-auth/) | Multiple authentication providers (API Key primary + Cognito + IAM additional) |
25-
| [lambda-resolvers-js](./lambda-resolvers-js/) | JS resolvers bundled with esbuild + Lambda data sources |
26-
| [lambda-resolvers-vtl](./lambda-resolvers-vtl/) | VTL request/response mapping templates |
27-
| [pipeline-resolvers](./pipeline-resolvers/) | Pipeline resolvers with reusable functions |
28-
| [datasource-http](./datasource-http/) | HTTP data source with optional IAM signing |
29-
| [datasource-none](./datasource-none/) | NONE data source (local resolvers) |
30-
| [datasource-eventbridge](./datasource-eventbridge/) | EventBridge data source |
31-
| [datasource-opensearch](./datasource-opensearch/) | Amazon OpenSearch Service data source |
32-
| [datasource-rds](./datasource-rds/) | Relational Database (Aurora Serverless) data source |
33-
| [caching](./caching/) | Server-side caching configuration |
34-
| [waf](./waf/) | AWS WAF v2 rules attached to the API |
35-
| [logging-xray](./logging-xray/) | Field-level logging plus X-Ray tracing |
36-
| [custom-domain](./custom-domain/) | Custom domain with route53 record management |
37-
| [introspection-disabled](./introspection-disabled/) | Disabled introspection and query depth limit |
38-
| [substitutions](./substitutions/) | VTL `${variable}` substitutions in resolvers |
39-
| [environment-variables](./environment-variables/) | Environment variables for JS resolvers |
40-
| [api-keys-multiple](./api-keys-multiple/) | Multiple API keys with different expiry policies |
41-
| [tags](./tags/) | Resource tagging on the AppSync API |
42-
| [visibility-private](./visibility-private/) | PRIVATE API visibility for VPC-only access |
43-
| [schema-multiple-files](./schema-multiple-files/) | Schema split across multiple `.graphql` files |
17+
| Example | What it shows |
18+
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
19+
| [basic-api-key](./basic-api-key/) | Simplest possible setup: API key auth, one DynamoDB data source, one resolver |
20+
| [cognito-userpools](./cognito-userpools/) | Cognito User Pools authentication with default action and user groups |
21+
| [iam-auth](./iam-auth/) | AWS IAM authentication |
22+
| [oidc-auth](./oidc-auth/) | OpenID Connect authentication |
23+
| [lambda-authorizer](./lambda-authorizer/) | Custom AWS Lambda authorizer |
24+
| [multi-auth](./multi-auth/) | Multiple authentication providers (API Key primary + Cognito + IAM additional) |
25+
| [lambda-resolvers-js](./lambda-resolvers-js/) | JS resolvers bundled with esbuild + Lambda data sources |
26+
| [lambda-resolvers-vtl](./lambda-resolvers-vtl/) | VTL request/response mapping templates |
27+
| [pipeline-resolvers](./pipeline-resolvers/) | Pipeline resolvers with reusable functions |
28+
| [datasource-http](./datasource-http/) | HTTP data source with optional IAM signing |
29+
| [datasource-none](./datasource-none/) | NONE data source (local resolvers) |
30+
| [datasource-eventbridge](./datasource-eventbridge/) | EventBridge data source |
31+
| [datasource-opensearch](./datasource-opensearch/) | Amazon OpenSearch Service data source |
32+
| [datasource-rds](./datasource-rds/) | Relational Database (Aurora Serverless) data source |
33+
| [caching](./caching/) | Server-side caching configuration |
34+
| [waf](./waf/) | AWS WAF v2 rules attached to the API |
35+
| [logging-xray](./logging-xray/) | Field-level logging plus X-Ray tracing |
36+
| [custom-domain](./custom-domain/) | Custom domain with route53 record management |
37+
| [introspection-disabled](./introspection-disabled/) | Disabled introspection and query depth limit |
38+
| [substitutions](./substitutions/) | VTL `${variable}` substitutions in resolvers |
39+
| [environment-variables](./environment-variables/) | Environment variables for JS resolvers |
40+
| [api-keys-multiple](./api-keys-multiple/) | Multiple API keys with different expiry policies |
41+
| [tags](./tags/) | Resource tagging on the AppSync API |
42+
| [visibility-private](./visibility-private/) | PRIVATE API visibility for VPC-only access |
43+
| [schema-multiple-files](./schema-multiple-files/) | Schema split across multiple `.graphql` files |
44+
| [sync-config-versioned](./sync-config-versioned/) | DynamoDB conflict resolution (OPTIMISTIC_CONCURRENCY + AUTOMERGE) with delta sync |
45+
| [custom-domain-no-cfn](./custom-domain-no-cfn/) | Custom domain managed outside CloudFormation (via the plugin's CLI commands) |
46+
| [waf-pre-existing-arn](./waf-pre-existing-arn/) | Attach a pre-existing shared WAF WebACL by ARN |
47+
| [pipeline-resolver-with-code](./pipeline-resolver-with-code/) | Pipeline resolver with its own top-level JS (before/after handlers) plus per-function code |
48+
| [api-key-import-existing](./api-key-import-existing/) | Import an existing API key by ID (stable migration) alongside auto-generated keys |
4449

4550
## How to run an example
4651

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
type Query {
2+
hello: String
3+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
service: appsync-api-key-import-existing
2+
3+
provider:
4+
name: aws
5+
runtime: nodejs22.x
6+
7+
plugins:
8+
- serverless-appsync-plugin
9+
10+
# When `apiKeyId` is specified on an API key config, AppSync retains
11+
# the existing key value (the literal API key string) on next deploy
12+
# rather than rotating it. This is the right path when:
13+
#
14+
# - Migrating from another AppSync setup that already issued keys
15+
# to clients you can't easily re-key
16+
# - Doing blue/green deployments where consumers need a stable key
17+
# - Importing an AppSync stack that was previously managed manually
18+
#
19+
# Note: the API key VALUE itself is not in CFN. You'd typically
20+
# resolve it from a Secrets Manager parameter or env var. Here we
21+
# show the apiKeyId being passed through deterministically.
22+
23+
appSync:
24+
name: api-key-import-existing
25+
authentication:
26+
type: API_KEY
27+
apiKeys:
28+
- name: stable
29+
description: Stable key migrated from previous infrastructure
30+
apiKeyId: da2-rotated-stable-key-id-abc123
31+
expiresAfter: 365d
32+
- name: rotating
33+
description: Net-new key created by this stack
34+
expiresAfter: 90d
35+
36+
resolvers:
37+
Query.hello:
38+
kind: UNIT
39+
dataSource: none_ds
40+
41+
dataSources:
42+
none_ds:
43+
type: NONE
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
type Query {
2+
hello: String
3+
}

0 commit comments

Comments
 (0)