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
4 changes: 3 additions & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,15 @@ jobs:
env: {}
- package: graphile/graphile-settings
env: {}
- package: graphile/graphile-presigned-url-plugin
env: {}

env:
PGHOST: localhost
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
MINIO_ENDPOINT: http://localhost:9000
CDN_ENDPOINT: http://localhost:9000
AWS_ACCESS_KEY: minioadmin
AWS_SECRET_KEY: minioadmin
AWS_REGION: us-east-1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
/**
* Integration tests for s3-signer against a real MinIO instance.
*
* These tests exercise the presigned URL pipeline end-to-end:
* 1. generatePresignedPutUrl → PUT a file via the presigned URL
* 2. headObject → verify the file exists with correct metadata
* 3. generatePresignedGetUrl → GET the file via the presigned URL
*
* Requires MinIO running on localhost:9000 (docker-compose or CI service).
*/

import { S3Client } from '@aws-sdk/client-s3';
import { createS3Bucket } from '@constructive-io/s3-utils';

import {
generatePresignedPutUrl,
generatePresignedGetUrl,
headObject,
} from '../src/s3-signer';
import type { S3Config } from '../src/types';

// --- MinIO config (matches docker-compose.yml + CI env) ---

const MINIO_ENDPOINT = process.env.CDN_ENDPOINT || 'http://localhost:9000';
const AWS_REGION = process.env.AWS_REGION || 'us-east-1';
const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY || 'minioadmin';
const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY || 'minioadmin';
const TEST_BUCKET = 'presigned-url-test-bucket';

// --- S3 client + config ---

const s3Client = new S3Client({
credentials: {
accessKeyId: AWS_ACCESS_KEY,
secretAccessKey: AWS_SECRET_KEY,
},
region: AWS_REGION,
endpoint: MINIO_ENDPOINT,
forcePathStyle: true,
});

const s3Config: S3Config = {
client: s3Client,
bucket: TEST_BUCKET,
endpoint: MINIO_ENDPOINT,
region: AWS_REGION,
forcePathStyle: true,
};

jest.setTimeout(30000);

// --- Setup / Teardown ---

beforeAll(async () => {
const result = await createS3Bucket(s3Client, TEST_BUCKET, { provider: 'minio' });
if (!result.success) throw new Error('Failed to create test S3 bucket');
});

afterAll(() => {
s3Client.destroy();
});

// --- Test helpers ---

/**
* Upload content to a presigned PUT URL using native fetch.
*/
async function uploadToPresignedUrl(
url: string,
body: string,
contentType: string,
): Promise<Response> {
return fetch(url, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': String(Buffer.byteLength(body)),
},
body,
});
}

/**
* Download content from a presigned GET URL using native fetch.
*/
async function downloadFromPresignedUrl(url: string): Promise<{
status: number;
body: string;
contentType: string | null;
}> {
const response = await fetch(url);
const body = await response.text();
return {
status: response.status,
body,
contentType: response.headers.get('content-type'),
};
}

// --- Tests ---

describe('s3-signer integration (MinIO)', () => {
describe('generatePresignedPutUrl', () => {
it('should generate a presigned PUT URL that accepts a valid upload', async () => {
const key = 'test-put-basic.txt';
const content = 'Hello, presigned upload!';
const contentType = 'text/plain';
const contentLength = Buffer.byteLength(content);

const putUrl = await generatePresignedPutUrl(
s3Config,
key,
contentType,
contentLength,
900,
);

expect(putUrl).toBeDefined();
expect(putUrl).toContain(TEST_BUCKET);
expect(putUrl).toContain(key);

// Actually upload via the presigned URL
const response = await uploadToPresignedUrl(putUrl, content, contentType);
expect(response.status).toBe(200);
});

it('should generate unique URLs for different keys', async () => {
const contentType = 'text/plain';
const contentLength = 5;

const url1 = await generatePresignedPutUrl(s3Config, 'key-a.txt', contentType, contentLength);
const url2 = await generatePresignedPutUrl(s3Config, 'key-b.txt', contentType, contentLength);

expect(url1).not.toBe(url2);
expect(url1).toContain('key-a.txt');
expect(url2).toContain('key-b.txt');
});

it('should respect custom expiry', async () => {
const url = await generatePresignedPutUrl(s3Config, 'expiry-test.txt', 'text/plain', 5, 60);
// The URL should contain expiry-related query params
expect(url).toContain('X-Amz-Expires=60');
});
});

describe('headObject', () => {
const HEAD_KEY = 'test-head-object.json';
const HEAD_CONTENT = JSON.stringify({ test: true });
const HEAD_CONTENT_TYPE = 'application/json';

beforeAll(async () => {
// Upload a file first so we can HEAD it
const putUrl = await generatePresignedPutUrl(
s3Config,
HEAD_KEY,
HEAD_CONTENT_TYPE,
Buffer.byteLength(HEAD_CONTENT),
);
const res = await uploadToPresignedUrl(putUrl, HEAD_CONTENT, HEAD_CONTENT_TYPE);
if (res.status !== 200) throw new Error(`Setup upload failed: ${res.status}`);
});

it('should return metadata for an existing object', async () => {
const result = await headObject(s3Config, HEAD_KEY);

expect(result).not.toBeNull();
expect(result!.contentType).toBe(HEAD_CONTENT_TYPE);
expect(result!.contentLength).toBe(Buffer.byteLength(HEAD_CONTENT));
});

it('should return null for a non-existent object', async () => {
const result = await headObject(s3Config, 'does-not-exist-' + Date.now());
expect(result).toBeNull();
});

it('should log a warning when content-type mismatches (but still return metadata)', async () => {
const result = await headObject(s3Config, HEAD_KEY, 'text/plain');

// headObject still returns metadata even on mismatch — it just logs a warning
expect(result).not.toBeNull();
expect(result!.contentType).toBe(HEAD_CONTENT_TYPE); // actual type, not expected
});
});

describe('generatePresignedGetUrl', () => {
const GET_KEY = 'test-get-download.txt';
const GET_CONTENT = 'Downloadable content for presigned GET test';
const GET_CONTENT_TYPE = 'text/plain';

beforeAll(async () => {
const putUrl = await generatePresignedPutUrl(
s3Config,
GET_KEY,
GET_CONTENT_TYPE,
Buffer.byteLength(GET_CONTENT),
);
const res = await uploadToPresignedUrl(putUrl, GET_CONTENT, GET_CONTENT_TYPE);
if (res.status !== 200) throw new Error(`Setup upload failed: ${res.status}`);
});

it('should generate a presigned GET URL that returns the file content', async () => {
const getUrl = await generatePresignedGetUrl(s3Config, GET_KEY, 3600);

expect(getUrl).toBeDefined();
expect(getUrl).toContain(TEST_BUCKET);
expect(getUrl).toContain(GET_KEY);

const { status, body, contentType } = await downloadFromPresignedUrl(getUrl);
expect(status).toBe(200);
expect(body).toBe(GET_CONTENT);
expect(contentType).toContain('text/plain');
});

it('should include Content-Disposition when filename is provided', async () => {
const getUrl = await generatePresignedGetUrl(s3Config, GET_KEY, 3600, 'my-download.txt');

expect(getUrl).toContain('response-content-disposition');

const response = await fetch(getUrl);
expect(response.status).toBe(200);

const disposition = response.headers.get('content-disposition');
expect(disposition).toContain('my-download.txt');
});

it('should respect custom expiry', async () => {
const url = await generatePresignedGetUrl(s3Config, GET_KEY, 120);
expect(url).toContain('X-Amz-Expires=120');
});
});

describe('full round-trip: PUT → HEAD → GET', () => {
it('should upload, verify, and download a text payload', async () => {
const key = 'roundtrip-test-' + Date.now() + '.txt';
const content = 'round-trip integration test content — special chars: é, ñ, ü';
const contentType = 'text/plain';

// 1. Generate presigned PUT URL
const putUrl = await generatePresignedPutUrl(
s3Config,
key,
contentType,
Buffer.byteLength(content),
);

// 2. Upload via presigned URL
const putResponse = await uploadToPresignedUrl(putUrl, content, contentType);
expect(putResponse.status).toBe(200);

// 3. HEAD — verify object exists with correct metadata
const headResult = await headObject(s3Config, key, contentType);
expect(headResult).not.toBeNull();
expect(headResult!.contentType).toBe(contentType);
expect(headResult!.contentLength).toBe(Buffer.byteLength(content));

// 4. Generate presigned GET URL
const getUrl = await generatePresignedGetUrl(s3Config, key, 3600);

// 5. Download and verify content
const { status, body } = await downloadFromPresignedUrl(getUrl);
expect(status).toBe(200);
expect(body).toBe(content);
});

it('should handle content-addressed keys (SHA-256 hex)', async () => {
// Simulates the actual plugin pattern: key = contentHash
const contentHash = 'a'.repeat(64); // fake SHA-256 hex
const content = 'content-addressed file data';
const contentType = 'text/plain';

const putUrl = await generatePresignedPutUrl(
s3Config,
contentHash,
contentType,
Buffer.byteLength(content),
);
const putRes = await uploadToPresignedUrl(putUrl, content, contentType);
expect(putRes.status).toBe(200);

const headResult = await headObject(s3Config, contentHash, contentType);
expect(headResult).not.toBeNull();
expect(headResult!.contentType).toBe(contentType);

const getUrl = await generatePresignedGetUrl(s3Config, contentHash, 3600);
const { status, body } = await downloadFromPresignedUrl(getUrl);
expect(status).toBe(200);
expect(body).toBe(content);
});

it('should handle various MIME types correctly', async () => {
const testCases = [
{ ext: 'html', type: 'text/html', content: '<h1>Hello</h1>' },
{ ext: 'json', type: 'application/json', content: '{"key":"value"}' },
{ ext: 'csv', type: 'text/csv', content: 'a,b,c\n1,2,3' },
];

for (const { ext, type, content } of testCases) {
const key = `mime-test-${Date.now()}.${ext}`;

const putUrl = await generatePresignedPutUrl(
s3Config,
key,
type,
Buffer.byteLength(content),
);
const putRes = await uploadToPresignedUrl(putUrl, content, type);
expect(putRes.status).toBe(200);

const headResult = await headObject(s3Config, key, type);
expect(headResult).not.toBeNull();
expect(headResult!.contentType).toBe(type);

const getUrl = await generatePresignedGetUrl(s3Config, key, 3600);
const { status, body } = await downloadFromPresignedUrl(getUrl);
expect(status).toBe(200);
expect(body).toBe(content);
}
});
});
});
1 change: 1 addition & 0 deletions graphile/graphile-presigned-url-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"postgraphile": "5.0.0"
},
"devDependencies": {
"@constructive-io/s3-utils": "workspace:^",
"@types/node": "^22.19.11",
"makage": "^0.1.10"
}
Expand Down
Loading
Loading