Skip to content

Commit 74d5a9a

Browse files
authored
Merge pull request #11 from gio-shara-code/localstack-test-integration
2 parents a09d2be + af5cd16 commit 74d5a9a

13 files changed

Lines changed: 373 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,20 @@ jobs:
2626
- name: Install Dependencies
2727
run: pnpm install
2828

29+
- name: LocalStack Docker Image Cache
30+
uses: actions/cache@v3
31+
id: cache-docker-localstack
32+
with:
33+
path: ci/cache/docker/localstack
34+
key: localstack-2.2
35+
36+
- name: Store LocalStack Docker Image Cache
37+
if: steps.cache-docker-localstack.outputs.cache-hit != 'true'
38+
run: docker pull localstack/localstack:2.2 && mkdir -p ci/cache/docker/localstack && docker image save localstack/localstack --output ./ci/cache/docker/localstack/localstack.tar
39+
40+
- name: Load LocalStack Docker Image Cache
41+
if: steps.cache-docker-localstack.outputs.cache-hit == 'true'
42+
run: docker image load --input ./ci/cache/docker/localstack/localstack.tar
43+
2944
- name: Test
3045
run: pnpm test
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { setupS3Mock } from './s3-mock/localstack'
2+
3+
export default setupS3Mock
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { teardownS3Mock } from './s3-mock/localstack'
2+
3+
export default teardownS3Mock
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { S3KeyResolvers } from '../../src/utils/s3-key-resolver'
2+
3+
export const expectS3Url = (
4+
url: URL,
5+
path: string,
6+
fileId: string,
7+
isUpload = true
8+
) => {
9+
const s3PathResolver = new S3KeyResolvers({
10+
uploadBucketPath: path,
11+
resourceBucketPath: [path],
12+
})
13+
14+
expect(url.protocol).toBe('http:')
15+
expect(url.hostname).toBe('test-bucket.s3.localhost.localstack.cloud')
16+
expect(url.port).toBe('4566')
17+
18+
const pathname = url.pathname.slice(1)
19+
20+
if (isUpload) {
21+
expect(pathname).toBe(s3PathResolver.upload.resolve(fileId))
22+
} else {
23+
expect(s3PathResolver.resource.resolve(fileId)).toContain(pathname)
24+
}
25+
26+
const searchParams = url.searchParams
27+
28+
expect(searchParams.get('X-Amz-Algorithm')).toBe('AWS4-HMAC-SHA256')
29+
expect(searchParams.get('X-Amz-Content-Sha256')).toBe('UNSIGNED-PAYLOAD')
30+
31+
const credentialDateString = new Date()
32+
.toISOString()
33+
.replace(/T.*/g, '')
34+
.replace(/[:-]/g, '')
35+
36+
expect(searchParams.get('X-Amz-Credential')).toBe(
37+
`test/${credentialDateString}/eu-central-1/s3/aws4_request`
38+
)
39+
expect(searchParams.get('X-Amz-SignedHeaders')).toBe('host')
40+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
AWS_PROFILE=localstack_test_profile
2+
3+
setup: aws-profile-setup localstack-up s3-create
4+
5+
reset: teardown setup
6+
reset-soft: s3-reset
7+
8+
teardown: localstack-teardown
9+
teardown-soft: s3-delete localstack-stop
10+
11+
localstack-status:
12+
@echo "Checking localstack status..."
13+
@docker compose ps -a
14+
15+
localstack-teardown:
16+
@echo "Tearing down localstack..."
17+
@docker compose down -v
18+
19+
localstack-pause:
20+
@echo "Pausing localstack..."
21+
@docker compose pause
22+
23+
localstack-stop:
24+
@echo "Stopping localstack..."
25+
@docker compose stop
26+
27+
localstack-up:
28+
@echo "Starting localstack..."
29+
@docker inspect -f '{{.State.Status}}' s3-mock-localstack | grep 'paused' > /dev/null && \
30+
docker compose unpause || \
31+
docker compose up -d
32+
33+
aws-profile-setup:
34+
@echo "Setting AWS profile..."
35+
@aws configure --profile $AWS_PROFILE set aws_access_key_id test
36+
@aws configure --profile $AWS_PROFILE set aws_secret_access_key test
37+
@aws configure --profile $AWS_PROFILE set region eu-central-1
38+
39+
s3-delete:
40+
@echo "Deleting S3 Bucket..."
41+
@aws --endpoint-url=http://localhost:4566 \
42+
--profile=$AWS_PROFILE \
43+
s3api delete-bucket \
44+
--bucket test-bucket
45+
46+
s3-exists:
47+
@echo "Checking if S3 Bucket exists..."
48+
@aws --endpoint-url=http://localhost:4566 \
49+
--profile=$AWS_PROFILE \
50+
s3api head-bucket \
51+
--bucket test-bucket
52+
53+
s3-create:
54+
@echo "Creating S3 Bucket..."
55+
@make s3-exists > /dev/null && echo "Bucket Already exists" && make s3-delete || echo "Bucket does not exist"
56+
57+
@aws --endpoint-url=http://localhost:4566 \
58+
--profile=$AWS_PROFILE \
59+
s3api create-bucket \
60+
--bucket test-bucket \
61+
--object-ownership BucketOwnerEnforced \
62+
--create-bucket-configuration LocationConstraint=eu-central-1
63+
64+
@echo "S3 Bucket created"
65+
66+
67+
s3-reset:
68+
@echo "Resetting S3..."
69+
@aws --endpoint-url=http://localhost:4566 \
70+
--profile=$AWS_PROFILE \
71+
s3 rm s3://test-bucket/upload --recursive
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
version: "3.8"
2+
3+
services:
4+
localstack:
5+
container_name: s3-mock-localstack
6+
image: localstack/localstack:2.2
7+
ports:
8+
- '4566:4566' # LocalStack endpoint
9+
- '4510-4559:4510-4559' # external services port range
10+
environment:
11+
- PERSISTENCE=1
12+
- DOCKER_HOST=unix:///var/run/docker.sock
13+
volumes:
14+
- '/var/run/docker.sock:/var/run/docker.sock'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { spawn, SpawnOptions } from 'child_process'
2+
3+
const childProcessWrapper = (
4+
command: string,
5+
args?: readonly string[],
6+
options?: SpawnOptions
7+
) => {
8+
const childProcess = spawn(command, args, {
9+
cwd: __dirname,
10+
...options,
11+
})
12+
13+
return new Promise((resolve, reject) => {
14+
childProcess.stdout.on('data', (data) => {
15+
process.stdout.write(data.toString())
16+
})
17+
18+
childProcess.stderr.on('data', (data) => {
19+
process.stdout.write(data.toString())
20+
})
21+
22+
childProcess.on('close', (code) => {
23+
if (code === 0) {
24+
return resolve(void 0)
25+
} else {
26+
return reject(new Error(`exit code ${code}`))
27+
}
28+
})
29+
})
30+
}
31+
32+
export const setupS3Mock = async () => {
33+
try {
34+
await childProcessWrapper('make', ['setup'], {
35+
timeout: 60 * 3 * 1000,
36+
})
37+
} catch (e) {
38+
throw new Error(`S3 mock setup failed, reason: ${e}`)
39+
}
40+
}
41+
42+
export const teardownS3Mock = async () => {
43+
try {
44+
if (process.env.CI) {
45+
await childProcessWrapper('make', ['teardown'], {
46+
timeout: 60 * 1000,
47+
})
48+
} else {
49+
await childProcessWrapper('make', ['teardown-soft'], {
50+
timeout: 60 * 1000,
51+
})
52+
}
53+
} catch (e) {
54+
throw new Error(`S3 mock teardown failed, reason: ${e}`)
55+
}
56+
}
57+
58+
export const resetS3Mock = async () => {
59+
try {
60+
await childProcessWrapper('make', ['reset-soft'], {
61+
timeout: 1000,
62+
})
63+
} catch (e) {
64+
throw new Error(`S3 mock reset failed, reason: ${e}`)
65+
}
66+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { S3Provider, S3ProviderConfiguration } from '../src'
2+
import { FileStatus } from 'shared-types'
3+
import { afterEach } from 'node:test'
4+
import { resetS3Mock } from './s3-mock/localstack'
5+
import { addFile, uploadFile } from './utils/file-upload'
6+
import { expectS3Url } from './helpers/s3-url'
7+
8+
describe('S3Provider', () => {
9+
const options: S3ProviderConfiguration = {
10+
bucketPath: 'upload',
11+
bucketRegion: 'eu-central-1',
12+
bucketName: 'test-bucket',
13+
}
14+
15+
afterEach(async () => {
16+
await resetS3Mock()
17+
})
18+
19+
const file = new Blob(['Hello World'], { type: 'text/plain' })
20+
21+
const fileId = '1234'
22+
const expiresIn = 2000
23+
24+
it('should return a correctly signed upload url', async () => {
25+
const provider = new S3Provider({
26+
...options,
27+
})
28+
29+
const { url, id, expiry } = await provider.signedUploadUrl(
30+
fileId,
31+
expiresIn
32+
)
33+
34+
const date = new Date(expiry)
35+
const parsedDate = date
36+
.toISOString()
37+
.replace(/[:-]/g, '')
38+
.replace(/\.\d{3}/, '')
39+
40+
const parsedUrl = new URL(url)
41+
const searchParams = parsedUrl.searchParams
42+
43+
expectS3Url(parsedUrl, 'upload', fileId)
44+
45+
expect(searchParams.get('X-Amz-Date')).toBe(parsedDate)
46+
expect(searchParams.get('X-Amz-Expires')).toBe(expiresIn.toString())
47+
expect(searchParams.get('x-amz-acl')).toBe('bucket-owner-full-control')
48+
expect(searchParams.get('x-id')).toBe('PutObject')
49+
50+
expect(id).toBe(fileId)
51+
})
52+
53+
test('uploading a file to the signed url', async () => {
54+
const provider = new S3Provider({
55+
...options,
56+
})
57+
58+
const { url } = await provider.signedUploadUrl(fileId, expiresIn)
59+
60+
await uploadFile(url, file)
61+
})
62+
63+
const cases: Partial<S3ProviderConfiguration>[] = [
64+
{
65+
optimisticFileDataResponse: true,
66+
},
67+
{
68+
optimisticFileDataResponse: false,
69+
},
70+
]
71+
72+
it.each(cases)(
73+
'should return a correctly signed download url, %p',
74+
async (p) => {
75+
const provider = new S3Provider<string>({
76+
...options,
77+
...p,
78+
})
79+
80+
await addFile(provider, fileId, file)
81+
82+
const { status, variants } = await provider.getData(fileId)
83+
84+
const url = new URL(variants[0])
85+
const searchParams = url.searchParams
86+
87+
expect(status).toBe(FileStatus.PROCESSED)
88+
expect(variants).toHaveLength(1)
89+
90+
expectS3Url(url, 'upload', fileId, false)
91+
92+
expect(searchParams.get('X-Amz-Expires')).toBe('900')
93+
expect(searchParams.get('x-id')).toBe('GetObject')
94+
}
95+
)
96+
97+
it('should delete a file', async () => {
98+
const provider = new S3Provider<string>({
99+
...options,
100+
// TODO: When set to `true` the status of the file is "PROCESSED"
101+
// since it does not check if the file actually exists
102+
optimisticFileDataResponse: false,
103+
})
104+
105+
await addFile(provider, fileId, file)
106+
107+
await provider.delete(fileId)
108+
109+
const { status } = await provider.getData(fileId)
110+
111+
expect(status).toBe(FileStatus.NOT_FOUND)
112+
})
113+
})
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
process.env.AWS_ACCESS_KEY_ID = 'aws_access_key_id'
2-
process.env.AWS_SECRET_ACCESS_KEY = 'aws_secret_access_key'
1+
process.env.AWS_ACCESS_KEY_ID = 'test'
2+
process.env.AWS_SECRET_ACCESS_KEY = 'test'
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { S3Provider } from '../../src'
2+
3+
export const uploadFile = async (url: string, file: Blob) => {
4+
const response = await fetch(url, {
5+
method: 'PUT',
6+
body: file,
7+
})
8+
9+
expect(response.status).toBe(200)
10+
expect(response.ok).toBe(true)
11+
}
12+
13+
export const addFile = async (
14+
provider: S3Provider<string>,
15+
id: string,
16+
file: Blob
17+
) => {
18+
const { url } = await provider.signedUploadUrl(id, 2000)
19+
await uploadFile(url, file)
20+
}

0 commit comments

Comments
 (0)