Skip to content

Commit e5ee11c

Browse files
committed
test: add MinIO integration tests for presigned URL s3-signer (Step 2h)
1 parent f548b4e commit e5ee11c

4 files changed

Lines changed: 2923 additions & 7475 deletions

File tree

.github/workflows/run-tests.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,15 @@ jobs:
109109
env: {}
110110
- package: graphile/graphile-settings
111111
env: {}
112+
- package: graphile/graphile-presigned-url-plugin
113+
env: {}
112114

113115
env:
114116
PGHOST: localhost
115117
PGPORT: 5432
116118
PGUSER: postgres
117119
PGPASSWORD: password
118-
MINIO_ENDPOINT: http://localhost:9000
120+
CDN_ENDPOINT: http://localhost:9000
119121
AWS_ACCESS_KEY: minioadmin
120122
AWS_SECRET_KEY: minioadmin
121123
AWS_REGION: us-east-1
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
/**
2+
* Integration tests for s3-signer against a real MinIO instance.
3+
*
4+
* These tests exercise the presigned URL pipeline end-to-end:
5+
* 1. generatePresignedPutUrl → PUT a file via the presigned URL
6+
* 2. headObject → verify the file exists with correct metadata
7+
* 3. generatePresignedGetUrl → GET the file via the presigned URL
8+
*
9+
* Requires MinIO running on localhost:9000 (docker-compose or CI service).
10+
*/
11+
12+
import { S3Client } from '@aws-sdk/client-s3';
13+
import { createS3Bucket } from '@constructive-io/s3-utils';
14+
15+
import {
16+
generatePresignedPutUrl,
17+
generatePresignedGetUrl,
18+
headObject,
19+
} from '../src/s3-signer';
20+
import type { S3Config } from '../src/types';
21+
22+
// --- MinIO config (matches docker-compose.yml + CI env) ---
23+
24+
const MINIO_ENDPOINT = process.env.CDN_ENDPOINT || 'http://localhost:9000';
25+
const AWS_REGION = process.env.AWS_REGION || 'us-east-1';
26+
const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY || 'minioadmin';
27+
const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY || 'minioadmin';
28+
const TEST_BUCKET = 'presigned-url-test-bucket';
29+
30+
// --- S3 client + config ---
31+
32+
const s3Client = new S3Client({
33+
credentials: {
34+
accessKeyId: AWS_ACCESS_KEY,
35+
secretAccessKey: AWS_SECRET_KEY,
36+
},
37+
region: AWS_REGION,
38+
endpoint: MINIO_ENDPOINT,
39+
forcePathStyle: true,
40+
});
41+
42+
const s3Config: S3Config = {
43+
client: s3Client,
44+
bucket: TEST_BUCKET,
45+
endpoint: MINIO_ENDPOINT,
46+
region: AWS_REGION,
47+
forcePathStyle: true,
48+
};
49+
50+
jest.setTimeout(30000);
51+
52+
// --- Setup / Teardown ---
53+
54+
beforeAll(async () => {
55+
const result = await createS3Bucket(s3Client, TEST_BUCKET, { provider: 'minio' });
56+
if (!result.success) throw new Error('Failed to create test S3 bucket');
57+
});
58+
59+
afterAll(() => {
60+
s3Client.destroy();
61+
});
62+
63+
// --- Test helpers ---
64+
65+
/**
66+
* Upload content to a presigned PUT URL using native fetch.
67+
*/
68+
async function uploadToPresignedUrl(
69+
url: string,
70+
body: string,
71+
contentType: string,
72+
): Promise<Response> {
73+
return fetch(url, {
74+
method: 'PUT',
75+
headers: {
76+
'Content-Type': contentType,
77+
'Content-Length': String(Buffer.byteLength(body)),
78+
},
79+
body,
80+
});
81+
}
82+
83+
/**
84+
* Download content from a presigned GET URL using native fetch.
85+
*/
86+
async function downloadFromPresignedUrl(url: string): Promise<{
87+
status: number;
88+
body: string;
89+
contentType: string | null;
90+
}> {
91+
const response = await fetch(url);
92+
const body = await response.text();
93+
return {
94+
status: response.status,
95+
body,
96+
contentType: response.headers.get('content-type'),
97+
};
98+
}
99+
100+
// --- Tests ---
101+
102+
describe('s3-signer integration (MinIO)', () => {
103+
describe('generatePresignedPutUrl', () => {
104+
it('should generate a presigned PUT URL that accepts a valid upload', async () => {
105+
const key = 'test-put-basic.txt';
106+
const content = 'Hello, presigned upload!';
107+
const contentType = 'text/plain';
108+
const contentLength = Buffer.byteLength(content);
109+
110+
const putUrl = await generatePresignedPutUrl(
111+
s3Config,
112+
key,
113+
contentType,
114+
contentLength,
115+
900,
116+
);
117+
118+
expect(putUrl).toBeDefined();
119+
expect(putUrl).toContain(TEST_BUCKET);
120+
expect(putUrl).toContain(key);
121+
122+
// Actually upload via the presigned URL
123+
const response = await uploadToPresignedUrl(putUrl, content, contentType);
124+
expect(response.status).toBe(200);
125+
});
126+
127+
it('should generate unique URLs for different keys', async () => {
128+
const contentType = 'text/plain';
129+
const contentLength = 5;
130+
131+
const url1 = await generatePresignedPutUrl(s3Config, 'key-a.txt', contentType, contentLength);
132+
const url2 = await generatePresignedPutUrl(s3Config, 'key-b.txt', contentType, contentLength);
133+
134+
expect(url1).not.toBe(url2);
135+
expect(url1).toContain('key-a.txt');
136+
expect(url2).toContain('key-b.txt');
137+
});
138+
139+
it('should respect custom expiry', async () => {
140+
const url = await generatePresignedPutUrl(s3Config, 'expiry-test.txt', 'text/plain', 5, 60);
141+
// The URL should contain expiry-related query params
142+
expect(url).toContain('X-Amz-Expires=60');
143+
});
144+
});
145+
146+
describe('headObject', () => {
147+
const HEAD_KEY = 'test-head-object.json';
148+
const HEAD_CONTENT = JSON.stringify({ test: true });
149+
const HEAD_CONTENT_TYPE = 'application/json';
150+
151+
beforeAll(async () => {
152+
// Upload a file first so we can HEAD it
153+
const putUrl = await generatePresignedPutUrl(
154+
s3Config,
155+
HEAD_KEY,
156+
HEAD_CONTENT_TYPE,
157+
Buffer.byteLength(HEAD_CONTENT),
158+
);
159+
const res = await uploadToPresignedUrl(putUrl, HEAD_CONTENT, HEAD_CONTENT_TYPE);
160+
if (res.status !== 200) throw new Error(`Setup upload failed: ${res.status}`);
161+
});
162+
163+
it('should return metadata for an existing object', async () => {
164+
const result = await headObject(s3Config, HEAD_KEY);
165+
166+
expect(result).not.toBeNull();
167+
expect(result!.contentType).toBe(HEAD_CONTENT_TYPE);
168+
expect(result!.contentLength).toBe(Buffer.byteLength(HEAD_CONTENT));
169+
});
170+
171+
it('should return null for a non-existent object', async () => {
172+
const result = await headObject(s3Config, 'does-not-exist-' + Date.now());
173+
expect(result).toBeNull();
174+
});
175+
176+
it('should log a warning when content-type mismatches (but still return metadata)', async () => {
177+
const result = await headObject(s3Config, HEAD_KEY, 'text/plain');
178+
179+
// headObject still returns metadata even on mismatch — it just logs a warning
180+
expect(result).not.toBeNull();
181+
expect(result!.contentType).toBe(HEAD_CONTENT_TYPE); // actual type, not expected
182+
});
183+
});
184+
185+
describe('generatePresignedGetUrl', () => {
186+
const GET_KEY = 'test-get-download.txt';
187+
const GET_CONTENT = 'Downloadable content for presigned GET test';
188+
const GET_CONTENT_TYPE = 'text/plain';
189+
190+
beforeAll(async () => {
191+
const putUrl = await generatePresignedPutUrl(
192+
s3Config,
193+
GET_KEY,
194+
GET_CONTENT_TYPE,
195+
Buffer.byteLength(GET_CONTENT),
196+
);
197+
const res = await uploadToPresignedUrl(putUrl, GET_CONTENT, GET_CONTENT_TYPE);
198+
if (res.status !== 200) throw new Error(`Setup upload failed: ${res.status}`);
199+
});
200+
201+
it('should generate a presigned GET URL that returns the file content', async () => {
202+
const getUrl = await generatePresignedGetUrl(s3Config, GET_KEY, 3600);
203+
204+
expect(getUrl).toBeDefined();
205+
expect(getUrl).toContain(TEST_BUCKET);
206+
expect(getUrl).toContain(GET_KEY);
207+
208+
const { status, body, contentType } = await downloadFromPresignedUrl(getUrl);
209+
expect(status).toBe(200);
210+
expect(body).toBe(GET_CONTENT);
211+
expect(contentType).toContain('text/plain');
212+
});
213+
214+
it('should include Content-Disposition when filename is provided', async () => {
215+
const getUrl = await generatePresignedGetUrl(s3Config, GET_KEY, 3600, 'my-download.txt');
216+
217+
expect(getUrl).toContain('response-content-disposition');
218+
219+
const response = await fetch(getUrl);
220+
expect(response.status).toBe(200);
221+
222+
const disposition = response.headers.get('content-disposition');
223+
expect(disposition).toContain('my-download.txt');
224+
});
225+
226+
it('should respect custom expiry', async () => {
227+
const url = await generatePresignedGetUrl(s3Config, GET_KEY, 120);
228+
expect(url).toContain('X-Amz-Expires=120');
229+
});
230+
});
231+
232+
describe('full round-trip: PUT → HEAD → GET', () => {
233+
it('should upload, verify, and download a text payload', async () => {
234+
const key = 'roundtrip-test-' + Date.now() + '.txt';
235+
const content = 'round-trip integration test content — special chars: é, ñ, ü';
236+
const contentType = 'text/plain';
237+
238+
// 1. Generate presigned PUT URL
239+
const putUrl = await generatePresignedPutUrl(
240+
s3Config,
241+
key,
242+
contentType,
243+
Buffer.byteLength(content),
244+
);
245+
246+
// 2. Upload via presigned URL
247+
const putResponse = await uploadToPresignedUrl(putUrl, content, contentType);
248+
expect(putResponse.status).toBe(200);
249+
250+
// 3. HEAD — verify object exists with correct metadata
251+
const headResult = await headObject(s3Config, key, contentType);
252+
expect(headResult).not.toBeNull();
253+
expect(headResult!.contentType).toBe(contentType);
254+
expect(headResult!.contentLength).toBe(Buffer.byteLength(content));
255+
256+
// 4. Generate presigned GET URL
257+
const getUrl = await generatePresignedGetUrl(s3Config, key, 3600);
258+
259+
// 5. Download and verify content
260+
const { status, body } = await downloadFromPresignedUrl(getUrl);
261+
expect(status).toBe(200);
262+
expect(body).toBe(content);
263+
});
264+
265+
it('should handle content-addressed keys (SHA-256 hex)', async () => {
266+
// Simulates the actual plugin pattern: key = contentHash
267+
const contentHash = 'a'.repeat(64); // fake SHA-256 hex
268+
const content = 'content-addressed file data';
269+
const contentType = 'text/plain';
270+
271+
const putUrl = await generatePresignedPutUrl(
272+
s3Config,
273+
contentHash,
274+
contentType,
275+
Buffer.byteLength(content),
276+
);
277+
const putRes = await uploadToPresignedUrl(putUrl, content, contentType);
278+
expect(putRes.status).toBe(200);
279+
280+
const headResult = await headObject(s3Config, contentHash, contentType);
281+
expect(headResult).not.toBeNull();
282+
expect(headResult!.contentType).toBe(contentType);
283+
284+
const getUrl = await generatePresignedGetUrl(s3Config, contentHash, 3600);
285+
const { status, body } = await downloadFromPresignedUrl(getUrl);
286+
expect(status).toBe(200);
287+
expect(body).toBe(content);
288+
});
289+
290+
it('should handle various MIME types correctly', async () => {
291+
const testCases = [
292+
{ ext: 'html', type: 'text/html', content: '<h1>Hello</h1>' },
293+
{ ext: 'json', type: 'application/json', content: '{"key":"value"}' },
294+
{ ext: 'csv', type: 'text/csv', content: 'a,b,c\n1,2,3' },
295+
];
296+
297+
for (const { ext, type, content } of testCases) {
298+
const key = `mime-test-${Date.now()}.${ext}`;
299+
300+
const putUrl = await generatePresignedPutUrl(
301+
s3Config,
302+
key,
303+
type,
304+
Buffer.byteLength(content),
305+
);
306+
const putRes = await uploadToPresignedUrl(putUrl, content, type);
307+
expect(putRes.status).toBe(200);
308+
309+
const headResult = await headObject(s3Config, key, type);
310+
expect(headResult).not.toBeNull();
311+
expect(headResult!.contentType).toBe(type);
312+
313+
const getUrl = await generatePresignedGetUrl(s3Config, key, 3600);
314+
const { status, body } = await downloadFromPresignedUrl(getUrl);
315+
expect(status).toBe(200);
316+
expect(body).toBe(content);
317+
}
318+
});
319+
});
320+
});

graphile/graphile-presigned-url-plugin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"build": "makage build",
1515
"build:dev": "makage build --dev",
1616
"lint": "eslint . --fix",
17-
"test": "jest --passWithNoTests",
17+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --passWithNoTests",
1818
"test:watch": "jest --watch"
1919
},
2020
"publishConfig": {
@@ -55,6 +55,7 @@
5555
"postgraphile": "5.0.0"
5656
},
5757
"devDependencies": {
58+
"@constructive-io/s3-utils": "workspace:^",
5859
"@types/node": "^22.19.11",
5960
"makage": "^0.1.10"
6061
}

0 commit comments

Comments
 (0)