diff --git a/.agent-os/specs/2025-11-11-sts-credentials/plan.md b/.agent-os/specs/2025-11-11-sts-credentials/plan.md new file mode 100644 index 0000000..c7cafcf --- /dev/null +++ b/.agent-os/specs/2025-11-11-sts-credentials/plan.md @@ -0,0 +1,769 @@ +# Implementation Plan: STS Credentials for S3 Access + +## Overview + +Replace pre-signed URLs with AWS STS temporary credentials. Clients assume an IAM role via STS and pass temporary credentials to Lambda, which uses them to access both source PDFs and upload converted images. + +## Architecture + +### Current (Broken) Flow +1. Client generates pre-signed GET URL for source PDF +2. Client generates pre-signed PUT URL for destination "prefix" +3. Lambda downloads PDF using source URL +4. Lambda modifies destination URL path (invalidates signature) ❌ +5. Lambda uploads images (fails due to invalid signature) ❌ + +### New STS Flow +1. Client assumes IAM role via STS (gets temporary credentials) +2. Client sends: source bucket/key, destination bucket/prefix, temp credentials +3. Lambda uses temp credentials to create S3 client +4. Lambda downloads PDF from source bucket/key +5. Lambda uploads images to destination bucket/prefix +6. Credentials expire after 15-60 minutes + +## Benefits + +✅ **Works for any number of pages** - No pre-signing individual files +✅ **Client maintains control** - They scope the IAM policy +✅ **Simpler than current approach** - No pre-signed URL manipulation +✅ **Time-limited access** - Credentials auto-expire +✅ **Fixes existing bug** - No signature invalidation + +--- + +## Phase 1: AWS Infrastructure Setup + +### 1.1 Create IAM Role for PDF Conversion + +**Role Name**: `PdfConverterClientRole` + +**Trust Policy** (allows clients to assume the role): +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::ACCOUNT_ID:root" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "pdf-converter-client" + } + } + } + ] +} +``` + +**Permissions Policy** (attached to role): +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadSourcePDFs", + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::${SourceBucket}/${SourcePrefix}*" + }, + { + "Sid": "WriteConvertedImages", + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::${DestinationBucket}/${DestinationPrefix}*" + } + ] +} +``` + +**Note**: Clients can further scope permissions when assuming the role using session policies. + +### 1.2 CloudFormation Template Addition + +Update `template.yaml` to include the IAM role (optional - clients can create their own): + +```yaml +Resources: + PdfConverterClientRole: + Type: AWS::IAM::Role + Properties: + RoleName: PdfConverterClientRole + Description: Role for clients to access S3 for PDF conversion + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root' + Action: 'sts:AssumeRole' + Condition: + StringEquals: + 'sts:ExternalId': 'pdf-converter-client' + Policies: + - PolicyName: S3AccessPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: ReadSourcePDFs + Effect: Allow + Action: 's3:GetObject' + Resource: 'arn:aws:s3:::*/*' + - Sid: WriteConvertedImages + Effect: Allow + Action: 's3:PutObject' + Resource: 'arn:aws:s3:::*/*' + +Outputs: + PdfConverterClientRoleArn: + Description: ARN of the IAM role for clients + Value: !GetAtt PdfConverterClientRole.Arn + Export: + Name: !Sub '${AWS::StackName}-ClientRoleArn' +``` + +--- + +## Phase 2: API Specification Changes + +### 2.1 New Request Format + +**Endpoint**: `POST /convert` + +**Headers**: +- `Authorization: Bearer ` (unchanged) +- `Content-Type: application/json` + +**Request Body**: +```json +{ + "source": { + "bucket": "my-input-bucket", + "key": "pdfs/document.pdf" + }, + "destination": { + "bucket": "my-output-bucket", + "prefix": "converted/project-123/" + }, + "credentials": { + "accessKeyId": "ASIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "FQoGZXIvYXdzEPT//////////..." + }, + "webhook": "https://example.com/webhook", + "unique_id": "client-123" +} +``` + +**Response** (unchanged): +```json +{ + "message": "PDF conversion and upload completed", + "images": [ + "s3://my-output-bucket/converted/project-123/page-1.png", + "s3://my-output-bucket/converted/project-123/page-2.png" + ], + "unique_id": "client-123", + "status": "completed", + "pages_converted": 2, + "metadata": { + "pdf_page_count": 2, + "conversion_dpi": 300, + "image_format": "png" + } +} +``` + +### 2.2 Backward Compatibility (Optional) + +Support both old (pre-signed URL) and new (STS) formats during transition: + +```ruby +# Detect format based on request body structure +if request_body['credentials'] + # New STS format + process_with_sts(request_body) +elsif request_body['source'].start_with?('https://') + # Old pre-signed URL format (deprecated) + process_with_presigned_urls(request_body) +end +``` + +--- + +## Phase 3: Lambda Function Changes + +### 3.1 New Request Validator + +**File**: `pdf_converter/app/request_validator.rb` + +Add validation for new request format: + +```ruby +def validate_sts_request(request_body) + errors = [] + + # Validate source + source = request_body['source'] + if source.nil? || !source.is_a?(Hash) + errors << 'source must be an object with bucket and key' + else + errors << 'source.bucket is required' if source['bucket'].nil? || source['bucket'].empty? + errors << 'source.key is required' if source['key'].nil? || source['key'].empty? + end + + # Validate destination + destination = request_body['destination'] + if destination.nil? || !destination.is_a?(Hash) + errors << 'destination must be an object with bucket and prefix' + else + errors << 'destination.bucket is required' if destination['bucket'].nil? || destination['bucket'].empty? + errors << 'destination.prefix is required' if destination['prefix'].nil? || destination['prefix'].empty? + end + + # Validate credentials + credentials = request_body['credentials'] + if credentials.nil? || !credentials.is_a?(Hash) + errors << 'credentials object is required' + else + errors << 'credentials.accessKeyId is required' if credentials['accessKeyId'].nil? + errors << 'credentials.secretAccessKey is required' if credentials['secretAccessKey'].nil? + errors << 'credentials.sessionToken is required' if credentials['sessionToken'].nil? + end + + # Validate unique_id + if request_body['unique_id'].nil? || request_body['unique_id'].empty? + errors << 'unique_id is required' + end + + errors +end +``` + +### 3.2 New PDF Downloader + +**File**: `pdf_converter/app/pdf_downloader.rb` + +Replace HTTP download with S3 SDK: + +```ruby +class PdfDownloader + def initialize(credentials = nil) + @credentials = credentials + end + + # Download PDF from S3 using credentials + def download_from_s3(bucket, key) + s3_client = create_s3_client + + response = s3_client.get_object( + bucket: bucket, + key: key + ) + + { + success: true, + content: response.body.read, + metadata: { + content_type: response.content_type, + content_length: response.content_length + } + } + rescue Aws::S3::Errors::NoSuchKey + { success: false, error: 'Source PDF not found' } + rescue Aws::S3::Errors::AccessDenied + { success: false, error: 'Access denied to source PDF' } + rescue Aws::Errors::ServiceError => e + { success: false, error: "S3 error: #{e.message}" } + rescue StandardError => e + { success: false, error: "Download failed: #{e.message}" } + end + + private + + def create_s3_client + if @credentials + Aws::S3::Client.new( + access_key_id: @credentials['accessKeyId'], + secret_access_key: @credentials['secretAccessKey'], + session_token: @credentials['sessionToken'] + ) + else + # Fall back to default credentials (Lambda IAM role) + Aws::S3::Client.new + end + end +end +``` + +### 3.3 Update Image Uploader + +**File**: `pdf_converter/app/image_uploader.rb` + +Add S3 upload method: + +```ruby +class ImageUploader + def initialize(credentials = nil) + @credentials = credentials + @logger = Logger.new($stdout) if defined?(Logger) + end + + # Upload images to S3 using credentials + def upload_images_to_s3(bucket, prefix, image_paths) + s3_client = create_s3_client + uploaded_keys = [] + + image_paths.each_with_index do |image_path, index| + key = "#{prefix}page-#{index + 1}.png" + + s3_client.put_object( + bucket: bucket, + key: key, + body: File.read(image_path, mode: 'rb'), + content_type: 'image/png' + ) + + uploaded_keys << "s3://#{bucket}/#{key}" + log_info("Uploaded #{key}") + end + + { + success: true, + uploaded_urls: uploaded_keys + } + rescue Aws::S3::Errors::AccessDenied + { success: false, error: 'Access denied to destination bucket' } + rescue Aws::Errors::ServiceError => e + { success: false, error: "S3 upload error: #{e.message}" } + rescue StandardError => e + { success: false, error: "Upload failed: #{e.message}" } + end + + private + + def create_s3_client + if @credentials + Aws::S3::Client.new( + access_key_id: @credentials['accessKeyId'], + secret_access_key: @credentials['secretAccessKey'], + session_token: @credentials['sessionToken'] + ) + else + Aws::S3::Client.new + end + end +end +``` + +### 3.4 Update Main Handler + +**File**: `pdf_converter/app.rb` + +Update to use new format: + +```ruby +def process_pdf_conversion(request_body, start_time, response_builder) + unique_id = request_body['unique_id'] + output_dir = "/tmp/#{unique_id}" + credentials = request_body['credentials'] + + puts "Authentication successful for unique_id: #{unique_id}" + + # Download PDF from S3 + downloader = PdfDownloader.new(credentials) + download_result = downloader.download_from_s3( + request_body['source']['bucket'], + request_body['source']['key'] + ) + return handle_failure(download_result, response_builder, 'PDF download', output_dir) unless download_result[:success] + + pdf_content = download_result[:content] + puts "PDF downloaded successfully, size: #{pdf_content.bytesize} bytes" + + # Convert PDF to images (unchanged) + conversion_result = PdfConverter.new.convert_to_images( + pdf_content: pdf_content, + output_dir: output_dir, + unique_id: unique_id, + dpi: ENV['CONVERSION_DPI']&.to_i || 300 + ) + unless conversion_result[:success] + return handle_failure(conversion_result, response_builder, 'PDF conversion', output_dir) + end + + images = conversion_result[:images] + page_count = images.size + puts "PDF converted successfully: #{page_count} pages" + + # Upload images to S3 + uploader = ImageUploader.new(credentials) + upload_result = uploader.upload_images_to_s3( + request_body['destination']['bucket'], + request_body['destination']['prefix'], + images + ) + return handle_failure(upload_result, response_builder, 'Image upload', output_dir) unless upload_result[:success] + + uploaded_urls = upload_result[:uploaded_urls] + puts "Images uploaded successfully: #{uploaded_urls.size} files" + + # Send webhook notification (unchanged) + notify_webhook(request_body['webhook'], unique_id, uploaded_urls, page_count, start_time) + + # Clean up and return success + FileUtils.rm_rf(output_dir) + response_builder.success_response( + unique_id: unique_id, + uploaded_urls: uploaded_urls, + page_count: page_count, + metadata: conversion_result[:metadata] + ) +end +``` + +### 3.5 Security: Credential Sanitization + +**File**: `pdf_converter/lib/credential_sanitizer.rb` (new) + +```ruby +module CredentialSanitizer + # Sanitize credentials for logging + def self.sanitize(credentials) + return nil unless credentials + + { + 'accessKeyId' => mask_credential(credentials['accessKeyId']), + 'secretAccessKey' => '***REDACTED***', + 'sessionToken' => mask_credential(credentials['sessionToken']) + } + end + + private + + def self.mask_credential(value) + return nil unless value + return '***REDACTED***' if value.length < 8 + + "#{value[0..3]}...#{value[-4..]}" + end +end +``` + +Add to all logging: +```ruby +puts "Using credentials: #{CredentialSanitizer.sanitize(credentials)}" +``` + +--- + +## Phase 4: Testing Scripts Updates + +### 4.1 New STS Credential Generator + +**File**: `scripts/generate_sts_credentials.rb` + +```ruby +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile do + source 'https://rubygems.org' + gem 'aws-sdk-sts', '~> 1' +end + +require 'optparse' +require 'json' + +options = { + role_arn: nil, + duration: 3600, + external_id: 'pdf-converter-client', + source_bucket: nil, + source_prefix: nil, + dest_bucket: nil, + dest_prefix: nil +} + +OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options]" + + opts.on("-r", "--role-arn ARN", "IAM Role ARN to assume") { |v| options[:role_arn] = v } + opts.on("-d", "--duration SECONDS", Integer, "Credential duration (default: 3600)") { |v| options[:duration] = v } + opts.on("--source-bucket BUCKET", "Source S3 bucket") { |v| options[:source_bucket] = v } + opts.on("--source-prefix PREFIX", "Source S3 prefix") { |v| options[:source_prefix] = v } + opts.on("--dest-bucket BUCKET", "Destination S3 bucket") { |v| options[:dest_bucket] = v } + opts.on("--dest-prefix PREFIX", "Destination S3 prefix") { |v| options[:dest_prefix] = v } + opts.on("-h", "--help", "Show help") do + puts opts + exit + end +end.parse! + +# Create STS client +sts = Aws::STS::Client.new + +# Build session policy to scope permissions +session_policy = { + "Version" => "2012-10-17", + "Statement" => [] +} + +if options[:source_bucket] + prefix = options[:source_prefix] || '' + session_policy["Statement"] << { + "Effect" => "Allow", + "Action" => "s3:GetObject", + "Resource" => "arn:aws:s3:::#{options[:source_bucket]}/#{prefix}*" + } +end + +if options[:dest_bucket] + prefix = options[:dest_prefix] || '' + session_policy["Statement"] << { + "Effect" => "Allow", + "Action" => "s3:PutObject", + "Resource" => "arn:aws:s3:::#{options[:dest_bucket]}/#{prefix}*" + } +end + +# Assume role +response = sts.assume_role({ + role_arn: options[:role_arn], + role_session_name: "pdf-converter-#{Time.now.to_i}", + duration_seconds: options[:duration], + external_id: options[:external_id], + policy: session_policy.to_json +}) + +credentials = response.credentials + +puts JSON.pretty_generate({ + accessKeyId: credentials.access_key_id, + secretAccessKey: credentials.secret_access_key, + sessionToken: credentials.session_token, + expiration: credentials.expiration +}) +``` + +### 4.2 Update API Test Script + +**File**: `scripts/test_api.rb` (new) + +Complete end-to-end testing script: + +```ruby +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile do + source 'https://rubygems.org' + gem 'aws-sdk-sts', '~> 1' + gem 'aws-sdk-s3', '~> 1' + gem 'jwt', '~> 2.7' + gem 'aws-sdk-secretsmanager', '~> 1' + gem 'rexml' +end + +require 'net/http' +require 'json' +require 'optparse' + +# Parse options... +# Generate STS credentials... +# Generate JWT token... +# Make API request... +# Display results... +``` + +--- + +## Phase 5: Documentation Updates + +### 5.1 Update README.md + +**Section: Getting Started > Step 1.5: Create IAM Role** + +Add between "Create JWT Secret" and "Clone and Deploy": + +```markdown +### Step 2.5: Create IAM Role for S3 Access + +Create an IAM role that clients will assume to access S3: + +\`\`\`bash +# Create the role with trust policy +aws iam create-role \ + --role-name PdfConverterClientRole \ + --assume-role-policy-document file://trust-policy.json + +# Attach permissions policy +aws iam put-role-policy \ + --role-name PdfConverterClientRole \ + --policy-name S3AccessPolicy \ + --policy-document file://permissions-policy.json +\`\`\` + +See [docs/sts-setup.md](docs/sts-setup.md) for detailed IAM policy examples. +``` + +### 5.2 Update API Specification + +**Section: API Specification > POST /convert** + +Replace request body example with new format. + +### 5.3 Create STS Setup Guide + +**File**: `docs/sts-setup.md` (new) + +Comprehensive guide for: +- Creating IAM roles +- Configuring trust relationships +- Scoping permissions +- Testing with AWS CLI +- Troubleshooting + +--- + +## Phase 6: Testing & Validation + +### 6.1 Unit Tests + +**New test files**: +- `spec/app/pdf_downloader_sts_spec.rb` +- `spec/app/image_uploader_sts_spec.rb` +- `spec/app/request_validator_sts_spec.rb` + +### 6.2 Integration Tests + +**File**: `spec/integration/sts_integration_spec.rb` + +Test with LocalStack or real AWS: +- Assume role +- Download PDF +- Convert +- Upload images +- Verify results + +### 6.3 Security Tests + +- Expired credentials +- Invalid credentials +- Insufficient permissions +- Credential leakage in logs + +--- + +## Phase 7: Deployment & Migration + +### 7.1 Deployment Steps + +1. Deploy updated Lambda function +2. Deploy IAM role via CloudFormation +3. Update API documentation +4. Notify clients of new format +5. Monitor for errors + +### 7.2 Migration Strategy + +**Option A: Hard cutover** (if no existing users) +- Deploy new version +- Update all documentation + +**Option B: Gradual migration** (if existing users) +- Support both formats during transition period +- Add deprecation warnings for old format +- Set sunset date for pre-signed URL format +- Remove old code after sunset + +--- + +## Timeline Estimate + +| Phase | Tasks | Estimated Time | +|-------|-------|----------------| +| 1. AWS Infrastructure | IAM role, policies, CloudFormation | 2 hours | +| 2. API Changes | Request validation, specs | 1 hour | +| 3. Lambda Changes | Downloader, uploader, handler | 4 hours | +| 4. Testing Scripts | STS generator, test script | 2 hours | +| 5. Documentation | README, API docs, STS guide | 3 hours | +| 6. Testing | Unit, integration, security | 4 hours | +| 7. Deployment | Deploy, monitor, validate | 2 hours | +| **Total** | | **18 hours** | + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Credential leakage in logs | High | Sanitize all logging, security audit | +| Invalid credentials break service | Medium | Comprehensive validation, clear error messages | +| Performance impact | Low | STS overhead is minimal (~100ms) | +| Backward compatibility | Medium | Support both formats during transition | +| Client adoption complexity | Medium | Excellent documentation, example scripts | + +--- + +## Success Criteria + +✅ Lambda can download PDFs using STS credentials +✅ Lambda can upload images using STS credentials +✅ All unit tests pass +✅ Integration tests pass with real AWS +✅ No credentials logged +✅ Documentation updated +✅ Testing scripts work end-to-end +✅ API returns proper S3 URLs for uploaded images + +--- + +## Next Steps + +1. **Review this plan** - Get approval on approach +2. **Create feature branch** - `feature/sts-credentials` +3. **Implement Phase 1** - AWS infrastructure +4. **Implement Phases 2-3** - API and Lambda changes +5. **Implement Phase 4** - Testing scripts +6. **Implement Phase 5** - Documentation +7. **Execute Phase 6** - Testing +8. **Execute Phase 7** - Deployment + +--- + +## Questions for Consideration + +1. **Should we support both formats during transition?** + - Yes if existing users, No if greenfield + +2. **Who creates the IAM role - us or clients?** + - Template includes example role + - Clients can customize per their security requirements + +3. **Should we validate credential permissions before processing?** + - Could do a preflight check (HeadObject on source) + - Trade-off: adds latency vs. fails faster + +4. **What's the recommended credential expiration time?** + - Suggest 15-60 minutes + - Must be longer than max conversion time + +5. **Should webhook notification include S3 URLs or signed URLs?** + - S3 URLs (s3://bucket/key) - client controls access + - Or HTTPS URLs - requires client has public access + +--- + +## References + +- [AWS STS AssumeRole Documentation](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) +- [AWS SDK for Ruby - S3](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html) +- [IAM Roles for Temporary Credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use.html) +- [Session Policies](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html#policies_session) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8a97884..95ea0e8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -45,7 +45,9 @@ "Bash(SKIP_COVERAGE_MINIMUMS=true AWS_REGION=us-east-1 bundle exec rspec:*)", "Bash(env SKIP_COVERAGE_MINIMUMS=true AWS_REGION=us-east-1 bundle exec rspec:*)", "Skill(simplecov)", - "Bash(then grep -A 5 \"covered_percent\\|app.rb\\|request_validator\\|response_builder\\|s3_url_parser\\|url_validator\\|webhook_notifier\" coverage/index.html)" + "Bash(then grep -A 5 \"covered_percent\\|app.rb\\|request_validator\\|response_builder\\|s3_url_parser\\|url_validator\\|webhook_notifier\" coverage/index.html)", + "Bash(git pull:*)", + "Bash(chmod:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index baddb2c..a86cf84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,24 +99,37 @@ The Lambda function is configured with: ### POST /convert -Converts a PDF to images. +Converts a PDF to images using temporary AWS credentials. **Request Body:** ```json { - "source": "https://s3.amazonaws.com/bucket/input.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...", - "destination": "https://s3.amazonaws.com/bucket/output/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...", - "webhook": "https://example.com/webhook", - "unique_id": "client-123" + "source": { + "bucket": "my-bucket", + "key": "pdfs/document.pdf" + }, + "destination": { + "bucket": "my-bucket", + "prefix": "converted/" + }, + "credentials": { + "accessKeyId": "ASIA...", + "secretAccessKey": "...", + "sessionToken": "..." + }, + "unique_id": "client-123", + "webhook": "https://example.com/webhook" } ``` -**Important:** Both `source` and `destination` URLs must be pre-signed S3 URLs. Pre-signed URLs provide: +**Security Model:** The service uses temporary AWS STS credentials for S3 access: -- **Enhanced security**: No AWS credentials are exposed in the Lambda function -- **Fine-grained access control**: URLs have time-limited access and specific permissions (GET for source, PUT for destination) -- **Client control**: Clients generate URLs with their own AWS credentials, maintaining data sovereignty +- **Scoped permissions**: Credentials are limited to specific S3 buckets/prefixes +- **Time-limited access**: Credentials expire after 15 minutes +- **No long-term credentials**: No permanent AWS keys are stored or exposed +- **Client control**: Clients generate credentials by assuming their own IAM role +- **Preflight validation**: Service validates credentials before processing - **Audit trail**: All S3 access is logged under the client's AWS account **Response:** @@ -125,8 +138,8 @@ Converts a PDF to images. { "message": "PDF conversion and upload completed", "images": [ - "https://s3.amazonaws.com/bucket/output/client-123-0.png?...", - "https://s3.amazonaws.com/bucket/output/client-123-1.png?..." + "https://my-bucket.s3.amazonaws.com/converted/client-123-0.png", + "https://my-bucket.s3.amazonaws.com/converted/client-123-1.png" ], "unique_id": "client-123", "status": "completed", diff --git a/README.md b/README.md index 12296bc..2bfb5f8 100644 --- a/README.md +++ b/README.md @@ -138,40 +138,73 @@ token = jwt.encode(payload, secret, algorithm='HS256') print(f"Authorization: Bearer {token}") ``` -### Step 6: Test Your Deployment +### Step 6: Set Up IAM Role for Testing -Create pre-signed S3 URLs for source (PDF) and destination (images), then call the API: +The API requires temporary AWS credentials to access your S3 buckets. Set up an IAM role: ```bash -# Example using curl (replace with your actual URLs and token) +./scripts/setup_iam_role.rb \ + --source-bucket your-bucket \ + --dest-bucket your-bucket +``` + +Note the role ARN from the output. + +### Step 7: Test Your Deployment + +Generate temporary credentials and call the API: + +```bash +# 1. Generate JWT token +./scripts/generate_jwt_token.rb + +# 2. Generate temporary AWS credentials +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole + +# 3. Call the API (replace with your values) curl -X POST https://your-api-endpoint.amazonaws.com/Prod/convert \ -H "Authorization: Bearer YOUR_JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "source": "https://s3.amazonaws.com/your-bucket/input.pdf?X-Amz-...", - "destination": "https://s3.amazonaws.com/your-bucket/output/?X-Amz-...", - "webhook": "https://your-webhook-endpoint.com/notify", - "unique_id": "test-123" + "source": { + "bucket": "your-bucket", + "key": "pdfs/test.pdf" + }, + "destination": { + "bucket": "your-bucket", + "prefix": "output/" + }, + "credentials": { + "accessKeyId": "ASIA...", + "secretAccessKey": "...", + "sessionToken": "..." + }, + "unique_id": "test-123", + "webhook": "https://your-webhook-endpoint.com/notify" }' ``` -For instructions on generating pre-signed S3 URLs, see the [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). - ### Testing Scripts To simplify testing, this repository includes utility scripts in the `scripts/` directory. The scripts automatically install their dependencies on first run using `bundler/inline` - no manual gem installation needed! +**Setup IAM Role (one-time):** +```bash +./scripts/setup_iam_role.rb \ + --source-bucket my-bucket \ + --dest-bucket my-bucket +``` + **Generate JWT Token:** ```bash ./scripts/generate_jwt_token.rb ``` -**Generate Pre-signed S3 URLs:** +**Generate STS Credentials:** ```bash -./scripts/generate_presigned_urls.rb \ - --bucket my-bucket \ - --source-key pdfs/test.pdf \ - --dest-prefix output/ +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::ACCOUNT_ID:role/PdfConverterClientRole ``` See [scripts/README.md](scripts/README.md) for detailed usage instructions and examples. @@ -241,24 +274,54 @@ sam delete --stack-name content_processing # Delete the deployed stack ### POST /convert -Converts a PDF to images. +Converts a PDF to images using temporary AWS credentials. **Request Body:** ```json { - "source": "https://s3.amazonaws.com/bucket/input.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...", - "destination": "https://s3.amazonaws.com/bucket/output/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...", - "webhook": "https://example.com/webhook", - "unique_id": "client-123" + "source": { + "bucket": "my-bucket", + "key": "pdfs/document.pdf" + }, + "destination": { + "bucket": "my-bucket", + "prefix": "converted/" + }, + "credentials": { + "accessKeyId": "ASIA...", + "secretAccessKey": "...", + "sessionToken": "..." + }, + "unique_id": "client-123", + "webhook": "https://example.com/webhook" } ``` -**Important:** Both `source` and `destination` URLs must be pre-signed S3 URLs. Pre-signed URLs provide: +**Required Fields:** + +- `source.bucket`: S3 bucket containing the PDF +- `source.key`: S3 object key for the PDF (must end with `.pdf`) +- `destination.bucket`: S3 bucket for converted images +- `destination.prefix`: S3 prefix (folder path) for images +- `credentials`: Temporary AWS STS credentials with: + - `accessKeyId`: AWS access key (must start with `ASIA` or `AKIA`) + - `secretAccessKey`: AWS secret access key + - `sessionToken`: AWS session token +- `unique_id`: Unique identifier for this conversion (alphanumeric, underscores, and hyphens only) + +**Optional Fields:** + +- `webhook`: URL to receive completion notification -- **Enhanced security**: No AWS credentials are exposed in the Lambda function -- **Fine-grained access control**: URLs have time-limited access and specific permissions (GET for source, PUT for destination) -- **Client control**: Clients generate URLs with their own AWS credentials, maintaining data sovereignty +**Security Model:** + +The service uses temporary AWS credentials (STS) for enhanced security: + +- **Scoped permissions**: Credentials are limited to specific S3 buckets/prefixes +- **Time-limited access**: Credentials expire after 15 minutes +- **No long-term credentials**: No permanent AWS keys are stored or exposed +- **Client control**: Clients generate credentials by assuming their own IAM role - **Audit trail**: All S3 access is logged under the client's AWS account **Response:** @@ -267,8 +330,8 @@ Converts a PDF to images. { "message": "PDF conversion and upload completed", "images": [ - "https://s3.amazonaws.com/bucket/output/client-123-0.png?...", - "https://s3.amazonaws.com/bucket/output/client-123-1.png?..." + "https://my-bucket.s3.amazonaws.com/converted/client-123-0.png", + "https://my-bucket.s3.amazonaws.com/converted/client-123-1.png" ], "unique_id": "client-123", "status": "completed", @@ -281,6 +344,14 @@ Converts a PDF to images. } ``` +**Image Naming Convention:** + +Converted images are stored with the format: `{prefix}{unique_id}-{page_number}.png` + +Examples: +- `output/test-123-0.png` (first page) +- `output/test-123-1.png` (second page) + **Note:** The service processes PDFs synchronously and returns the converted images in the response. If a webhook URL is provided, a notification is also sent asynchronously (fire-and-forget) upon completion. ## Architecture @@ -308,16 +379,15 @@ The Lambda function uses these environment variables: ### Production - **jwt (~> 2.7)**: JSON Web Token implementation for authentication +- **aws-sdk-s3 (~> 1)**: AWS S3 SDK for direct S3 access with temporary credentials - **aws-sdk-secretsmanager (~> 1)**: AWS SDK for secure key retrieval - **json (~> 2.9)**: JSON parsing and generation - **ruby-vips (~> 2.2)**: Ruby bindings for libvips image processing library -- **async (~> 2.6)**: Asynchronous processing for batch uploads ### Testing - **rspec (~> 3.12)**: Testing framework - **webmock (~> 3.19)**: HTTP request stubbing for tests -- **aws-sdk-s3 (~> 1)**: AWS S3 SDK for integration tests - **simplecov (~> 0.22)**: Code coverage analysis ### Development diff --git a/docs/sts-setup.md b/docs/sts-setup.md new file mode 100644 index 0000000..97a5465 --- /dev/null +++ b/docs/sts-setup.md @@ -0,0 +1,419 @@ +# STS Credentials Setup Guide + +This guide explains how to set up AWS Security Token Service (STS) credentials for the PDF Converter API. + +## Table of Contents + +- [Why STS Credentials?](#why-sts-credentials) +- [Quick Start](#quick-start) +- [Detailed Setup](#detailed-setup) +- [Using the API](#using-the-api) +- [Code Examples](#code-examples) +- [Troubleshooting](#troubleshooting) + +## Why STS Credentials? + +The PDF Converter API uses temporary AWS credentials (STS) instead of pre-signed URLs for several reasons: + +**Security Benefits:** +- **Time-limited access**: Credentials expire after 15 minutes, limiting exposure window +- **No long-term credentials**: No permanent AWS keys are stored or transmitted +- **Scoped permissions**: Credentials are limited to specific S3 buckets and operations +- **Credential rotation**: Fresh credentials for each API call + +**Operational Benefits:** +- **Client control**: You generate credentials in your own AWS account +- **Audit trail**: All S3 access appears in your CloudTrail logs +- **Flexible permissions**: Customize IAM policies to match your security requirements +- **No URL manipulation**: Direct S3 SDK access instead of HTTP requests + +## Quick Start + +For testing, use our utility scripts: + +```bash +# 1. Create IAM role (one-time setup) +./scripts/setup_iam_role.rb \ + --source-bucket my-bucket \ + --dest-bucket my-bucket + +# 2. Generate temporary credentials +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::ACCOUNT_ID:role/PdfConverterClientRole + +# 3. Use the credentials in your API request +``` + +## Detailed Setup + +### Step 1: Create an IAM Role + +Create an IAM role in your AWS account that the PDF Converter can assume. + +**Using the AWS CLI:** + +```bash +# 1. Create trust policy document +cat > trust-policy.json < permissions-policy.json <= (@expiration - 60) + refresh_credentials + end + @credentials + end + + private + + def refresh_credentials + sts = Aws::STS::Client.new + response = sts.assume_role( + role_arn: @role_arn, + role_session_name: "pdf-converter-#{Time.now.to_i}", + external_id: 'pdf-converter-client', + duration_seconds: 900 + ) + + @credentials = { + accessKeyId: response.credentials.access_key_id, + secretAccessKey: response.credentials.secret_access_key, + sessionToken: response.credentials.session_token + } + @expiration = response.credentials.expiration + end +end +``` + +### "Invalid bucket name" or "Invalid key" + +**Cause**: Bucket names or keys don't meet AWS S3 requirements. + +**Solution**: Ensure: +- Bucket names are 3-63 characters, lowercase, numbers, dots, hyphens +- Keys are not empty and under 1024 characters +- Source keys end with `.pdf` + +## Security Best Practices + +1. **Scope permissions narrowly**: Only grant access to specific buckets/prefixes needed +2. **Use separate roles per application**: Don't share roles between different services +3. **Monitor CloudTrail logs**: Review AssumeRole calls and S3 access patterns +4. **Rotate ExternalId periodically**: Update the ExternalId in both the trust policy and your code +5. **Implement retry logic**: Handle credential expiration gracefully in your application +6. **Don't log credentials**: Never log the secretAccessKey or sessionToken + +## Additional Resources + +- [AWS STS Documentation](https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html) +- [IAM Roles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html) +- [AssumeRole API](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) +- [ExternalId Best Practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html) diff --git a/pdf_converter/Gemfile b/pdf_converter/Gemfile index ff7edec..8b6115f 100644 --- a/pdf_converter/Gemfile +++ b/pdf_converter/Gemfile @@ -4,28 +4,25 @@ source 'https://rubygems.org' ruby '~> 3.4' -# Testing -group :test do - gem 'aws-sdk-s3', '~> 1' - gem 'rspec', '~> 3.12' - gem 'simplecov', '~> 0.22', require: false - gem 'webmock', '~> 3.19' -end - # JSON handling gem 'json', '~> 2.9' # JWT authentication gem 'jwt', '~> 2.7' -# AWS SDK for Secrets Manager +# AWS SDK for S3 and Secrets Manager +gem 'aws-sdk-s3', '~> 1' gem 'aws-sdk-secretsmanager', '~> 1' # PDF to image conversion gem 'ruby-vips', '~> 2.2' -# Async processing for batch uploads -gem 'async', '~> 2.6' +# Testing +group :test do + gem 'rspec', '~> 3.12' + gem 'simplecov', '~> 0.22', require: false + gem 'webmock', '~> 3.19' +end group :development do gem 'rubocop', '~> 1.81' diff --git a/pdf_converter/app.rb b/pdf_converter/app.rb index d12b369..65c98cc 100644 --- a/pdf_converter/app.rb +++ b/pdf_converter/app.rb @@ -3,14 +3,12 @@ require 'json' require 'fileutils' require_relative 'app/jwt_authenticator' -require_relative 'app/url_validator' require_relative 'app/pdf_downloader' require_relative 'app/pdf_converter' require_relative 'app/image_uploader' require_relative 'app/request_validator' require_relative 'app/webhook_notifier' require_relative 'app/response_builder' -require_relative 'lib/url_utils' def lambda_handler(event:, context: nil) start_time = Time.now.to_f @@ -40,11 +38,14 @@ def lambda_handler(event:, context: nil) def process_pdf_conversion(request_body, start_time, response_builder) unique_id = request_body['unique_id'] output_dir = "/tmp/#{unique_id}" + credentials = request_body['credentials'] puts "Authentication successful for unique_id: #{unique_id}" - # Download PDF - download_result = PdfDownloader.new.download(request_body['source']) + # Download PDF from S3 + source = request_body['source'] + pdf_downloader = PdfDownloader.new(credentials) + download_result = pdf_downloader.download_from_s3(source['bucket'], source['key']) return handle_failure(download_result, response_builder, 'PDF download', output_dir) unless download_result[:success] pdf_content = download_result[:content] @@ -66,11 +67,20 @@ def process_pdf_conversion(request_body, start_time, response_builder) page_count = images.size puts "PDF converted successfully: #{page_count} pages" - # Upload images - upload_result = ImageUploader.new.upload_images_from_files(request_body['destination'], images) + # Upload images to S3 + destination = request_body['destination'] + image_uploader = ImageUploader.new(credentials) + upload_result = image_uploader.upload_images_to_s3( + destination['bucket'], + destination['prefix'], + unique_id, + images + ) return handle_failure(upload_result, response_builder, 'Image upload', output_dir) unless upload_result[:success] - uploaded_urls = upload_result[:uploaded_urls] + # Build S3 URLs for response + uploaded_keys = upload_result[:uploaded_keys] + uploaded_urls = build_s3_urls(destination['bucket'], uploaded_keys) puts "Images uploaded successfully: #{uploaded_urls.size} files" # Send webhook notification @@ -86,6 +96,15 @@ def process_pdf_conversion(request_body, start_time, response_builder) ) end +# Builds S3 URLs from bucket and keys. +# +# @param bucket [String] S3 bucket name +# @param keys [Array] Array of S3 object keys +# @return [Array] Array of S3 URLs +def build_s3_urls(bucket, keys) + keys.map { |key| "https://#{bucket}.s3.amazonaws.com/#{key}" } +end + # Handles operation failures consistently. # # @param result [Hash] Operation result hash diff --git a/pdf_converter/app/image_uploader.rb b/pdf_converter/app/image_uploader.rb index 77eb72a..ae0bee0 100644 --- a/pdf_converter/app/image_uploader.rb +++ b/pdf_converter/app/image_uploader.rb @@ -1,222 +1,135 @@ # frozen_string_literal: true -require 'net/http' -require 'uri' -require 'timeout' -require 'async' -require 'async/barrier' -require 'async/semaphore' -require_relative '../lib/retry_handler' -require_relative '../lib/url_utils' - -# ImageUploader handles uploading images to S3 using pre-signed URLs -# with proper error handling, retries, and concurrent upload support -class ImageUploader - include UrlUtils - - TIMEOUT_SECONDS = 60 # Longer timeout for uploads - THREAD_POOL_SIZE = 5 +require 'aws-sdk-s3' +require_relative '../lib/credential_sanitizer' - def initialize - @logger = Logger.new($stdout) if defined?(Logger) +# ImageUploader handles uploading converted images to S3 using temporary credentials. +# It supports both credential-based access (for clients) and IAM role-based access +# (for Lambda's default execution role). +class ImageUploader + def initialize(credentials = nil) + @credentials = credentials end - # Uploads a single image to S3 using a pre-signed URL - # @param url [String] The pre-signed S3 URL with PUT permissions - # @param content [String] The image content to upload - # @param content_type [String] The content type (e.g., 'image/png') - # @return [Hash] Result hash with :success, :etag, or :error - def upload(url, content, content_type = 'image/png') - validate_inputs(url, content) - - log_info("Starting image upload to: #{sanitize_url(url)}") + # Uploads image files to S3 destination bucket/prefix using credentials. + # Generates keys in the format: {prefix}/{unique_id}-{page_number}.png + # + # @param bucket [String] S3 destination bucket name + # @param prefix [String] S3 object prefix (folder path) + # @param unique_id [String] Unique identifier for this conversion + # @param image_paths [Array] Array of image file paths to upload + # @return [Hash] Result with :success, :uploaded_keys, :etags, or :error + def upload_images_to_s3(bucket, prefix, unique_id, image_paths) + log_upload_start(bucket, prefix, image_paths.size) + + s3_client = create_s3_client + uploaded_keys = [] + etags = [] - uri = URI.parse(url) - etag = upload_with_retry(uri, content, content_type) + image_paths.each_with_index do |image_path, index| + key = build_s3_key(prefix, unique_id, index) - log_info("Image upload completed successfully, ETag: #{etag}") + result = upload_single_image(s3_client, bucket, key, image_path) + return result unless result[:success] - { - success: true, - etag: etag, - size: content.bytesize - } - rescue ArgumentError => e - error_result(e.message) - rescue URI::InvalidURIError - error_result('Invalid URL format') - rescue StandardError => e - # Provide better error message for 403 errors - if e.message.include?('403') - error_result('Access denied - URL may be expired or invalid') - else - error_result("Upload failed: #{e.message}") + uploaded_keys << key + etags << result[:etag] end - end - - # Uploads multiple images concurrently to S3 using pre-signed URLs - # @param urls [Array] Array of pre-signed S3 URLs - # @param images [Array] Array of image contents to upload - # @param content_type [String] The content type for all images - # @return [Array] Array of result hashes for each upload - def upload_batch(urls, images, content_type = 'image/png') - raise ArgumentError, 'Number of URLs must match number of images' unless urls.size == images.size - - log_info("Starting batch upload of #{urls.size} images") - results = [] - - Async do - barrier = Async::Barrier.new - semaphore = Async::Semaphore.new(THREAD_POOL_SIZE, parent: barrier) - - urls.zip(images).each_with_index do |(url, content), index| - semaphore.async do - result = upload(url, content, content_type) - result[:index] = index - results << result - end - end - - # Wait for all uploads to complete - barrier.wait - end - - # Sort results by index to maintain order - results.sort_by! { |r| r[:index] } - successful = results.count { |r| r[:success] } - log_info("Batch upload completed: #{successful}/#{results.size} successful") + log_upload_success(uploaded_keys.size) - results - end - - # Uploads image files to S3 destination using pre-signed URL - # @param destination_url [String] Pre-signed S3 destination URL - # @param image_paths [Array] Array of image file paths - # @return [Hash] Result with :success, :uploaded_urls, :etags, or :error - def upload_images_from_files(destination_url, image_paths) - base_uri = parse_destination_url(destination_url) - image_urls, image_contents = prepare_images_for_upload(image_paths, base_uri) - - upload_results = upload_batch(image_urls, image_contents, 'image/png') - process_upload_results(upload_results, image_urls) - rescue StandardError => e { - success: false, - error: "Upload error: #{e.message}" + success: true, + uploaded_keys: uploaded_keys, + etags: etags } + rescue Aws::S3::Errors::AccessDenied + error_result('Access denied to destination bucket - check credentials permissions') + rescue Aws::Errors::ServiceError => e + error_result("S3 error: #{e.message}") + rescue StandardError => e + error_result("Upload failed: #{e.message}") end private - def validate_inputs(url, content) - raise ArgumentError, 'URL cannot be nil or empty' if url.nil? || url.empty? - raise ArgumentError, 'Content cannot be nil or empty' if content.nil? || content.empty? - end - - # Uploads content with retry logic for transient failures - # @param uri [URI] The URI to upload to - # @param content [String] The content to upload - # @param content_type [String] The content type - # @return [String] The ETag from the successful upload - def upload_with_retry(uri, content, content_type) - RetryHandler.with_retry(logger: @logger) do - perform_upload(uri, content, content_type) + # Uploads a single image to S3. + # + # @param s3_client [Aws::S3::Client] S3 client instance + # @param bucket [String] S3 bucket name + # @param key [String] S3 object key + # @param image_path [String] Path to image file + # @return [Hash] Result with :success, :etag, or :error + def upload_single_image(s3_client, bucket, key, image_path) + File.open(image_path, 'rb') do |file| + response = s3_client.put_object( + bucket: bucket, + key: key, + body: file, + content_type: 'image/png' + ) + + puts "Uploaded image: s3://#{bucket}/#{key}, ETag: #{response.etag}" + + { + success: true, + etag: response.etag + } end - rescue RetryHandler::RetryError => e - raise StandardError, e.message + rescue Aws::S3::Errors::ServiceError => e + error_result("Failed to upload #{key}: #{e.message}") end - def perform_upload(uri, content, content_type) - response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| - http.read_timeout = TIMEOUT_SECONDS - http.open_timeout = TIMEOUT_SECONDS - - request = Net::HTTP::Put.new(uri) - request['Content-Type'] = content_type - request['Content-Length'] = content.bytesize.to_s - request.body = content + # Builds S3 key from prefix, unique_id, and page number. + # Format: prefix/unique_id-page_number.png + # Example: output/test-123-0.png + # + # @param prefix [String] S3 prefix (may be empty) + # @param unique_id [String] Unique identifier + # @param page_number [Integer] Zero-based page number + # @return [String] S3 object key + def build_s3_key(prefix, unique_id, page_number) + prefix = prefix.to_s.strip + prefix = prefix.end_with?('/') ? prefix : "#{prefix}/" + prefix = '' if prefix == '/' + + "#{prefix}#{unique_id}-#{page_number}.png" + end - http.request(request) + # Creates an S3 client using the provided credentials or default IAM role. + # + # @return [Aws::S3::Client] Configured S3 client + def create_s3_client + if @credentials + Aws::S3::Client.new( + access_key_id: @credentials['accessKeyId'], + secret_access_key: @credentials['secretAccessKey'], + session_token: @credentials['sessionToken'] + ) + else + # Use Lambda's IAM role + Aws::S3::Client.new end + end - case response - when Net::HTTPSuccess - response['ETag'] || response['etag'] || 'no-etag' - when Net::HTTPRedirection - raise StandardError, 'Unexpected redirect during upload' + def log_upload_start(bucket, prefix, count) + if @credentials + sanitized = CredentialSanitizer.sanitize(@credentials) + puts "Uploading #{count} images to s3://#{bucket}/#{prefix} using credentials: #{sanitized['accessKeyId']}" else - raise StandardError, "HTTP #{response.code}: #{response.message}" + puts "Uploading #{count} images to s3://#{bucket}/#{prefix} using Lambda IAM role" end end + def log_upload_success(count) + puts "Successfully uploaded #{count} images to S3" + end + def error_result(message) - log_error(message) + puts "ERROR: #{message}" { success: false, error: message } end - - def log_info(message) - @logger&.info(message) || puts("INFO: #{message}") - end - - def log_error(message) - @logger&.error(message) || puts("ERROR: #{message}") - end - - # Parses the destination URL and returns a base URI with proper path. - # - # @param destination_url [String] Destination URL - # @return [URI] Base URI with normalized path - def parse_destination_url(destination_url) - uri = URI.parse(destination_url) - uri_path = uri.path - uri.path = uri_path.end_with?('/') ? uri_path : "#{uri_path}/" - uri - end - - # Prepares image URLs and contents for batch upload. - # - # @param image_paths [Array] Image file paths - # @param base_uri [URI] Base URI for uploads - # @return [Array] Two arrays: URLs and contents - def prepare_images_for_upload(image_paths, base_uri) - image_urls = [] - image_contents = [] - - image_paths.each_with_index do |image_path, index| - image_uri = base_uri.dup - image_uri.path = "#{base_uri.path}page-#{index + 1}.png" - - image_urls << image_uri.to_s - image_contents << File.read(image_path, mode: 'rb') - end - - [image_urls, image_contents] - end - - # Processes upload results and returns success or failure hash. - # - # @param upload_results [Array] Upload results - # @param image_urls [Array] Image URLs - # @return [Hash] Result with :success, :uploaded_urls, :etags, or :error - def process_upload_results(upload_results, image_urls) - failed_uploads = upload_results.reject { |result| result[:success] } - - if failed_uploads.any? - error_messages = failed_uploads.map { |result| result[:error] }.uniq.join(', ') - return { - success: false, - error: "Failed to upload #{failed_uploads.size} images: #{error_messages}" - } - end - - { - success: true, - uploaded_urls: UrlUtils.strip_query_params(image_urls), - etags: upload_results.map { |result| result[:etag] } - } - end end diff --git a/pdf_converter/app/pdf_downloader.rb b/pdf_converter/app/pdf_downloader.rb index 3c6987f..cbf65ac 100644 --- a/pdf_converter/app/pdf_downloader.rb +++ b/pdf_converter/app/pdf_downloader.rb @@ -1,46 +1,61 @@ # frozen_string_literal: true -require 'net/http' -require 'uri' -require 'timeout' -require_relative '../lib/retry_handler' -require_relative '../lib/url_utils' - -# PdfDownloader handles downloading PDF files from S3 signed URLs -# with proper error handling and content validation -class PdfDownloader - include UrlUtils +require 'aws-sdk-s3' +require_relative '../lib/credential_sanitizer' - TIMEOUT_SECONDS = 30 - MAX_REDIRECTS = 5 +# PdfDownloader handles downloading PDF files from S3 using temporary credentials. +# It supports both credential-based access (for clients) and IAM role-based access +# (for Lambda's default execution role). +class PdfDownloader + # Maximum PDF file size (100MB) + MAX_PDF_SIZE = 100 * 1024 * 1024 VALID_PDF_MAGIC_NUMBERS = ['%PDF-1.', '%PDF-2.'].freeze - def initialize - @logger = Logger.new($stdout) if defined?(Logger) + def initialize(credentials = nil) + @credentials = credentials end - # Downloads a PDF from the given signed S3 URL with retry logic - # @param url [String] The signed S3 URL to download from - # @return [Hash] Result hash with :success, :content, :content_type, or :error - def download(url) - validate_url(url) + # Downloads a PDF file from S3 using the provided bucket and key. + # Performs a preflight check to verify access and validate the file. + # + # @param bucket [String] S3 bucket name + # @param key [String] S3 object key + # @return [Hash] Result hash with :success, :content, :metadata, or :error + def download_from_s3(bucket, key) + log_download_start(bucket, key) - log_info("Starting PDF download from: #{sanitize_url(url)}") + # Preflight check: verify access and file properties + preflight_result = preflight_check(bucket, key) + return preflight_result unless preflight_result[:success] - uri = URI.parse(url) - content, content_type = download_with_retry(uri) + # Download the PDF content + s3_client = create_s3_client + response = s3_client.get_object(bucket: bucket, key: key) - return error_result('Invalid PDF content: Does not contain valid PDF header') unless validate_pdf_content(content) + content = response.body.read - log_info("PDF download completed successfully, size: #{content.bytesize} bytes") + # Validate PDF content + unless validate_pdf_content(content) + return error_result('Invalid PDF content: Does not contain valid PDF header') + end + + log_download_success(content.bytesize) { success: true, content: content, - content_type: content_type + metadata: { + content_type: response.content_type, + content_length: response.content_length, + etag: response.etag + } } - rescue URI::InvalidURIError - error_result('Invalid URL format') + rescue Aws::S3::Errors::NoSuchKey + error_result('Source PDF not found') + rescue Aws::S3::Errors::AccessDenied + error_result('Access denied to source PDF - check credentials permissions') + rescue Aws::Errors::ServiceError => e + error_result("S3 error: #{e.message}") rescue StandardError => e error_result("Download failed: #{e.message}") end @@ -57,72 +72,74 @@ def validate_pdf_content(content) private - # Downloads content with retry logic for transient failures - # @param uri [URI] The URI to download from - # @return [Array] Array containing [content, content_type] - def download_with_retry(uri) - RetryHandler.with_retry(logger: @logger) do - fetch_with_redirects(uri) + # Performs a preflight check on the S3 object to verify: + # 1. Object exists and credentials have access + # 2. File size is within limits + # 3. Content type is PDF (if metadata available) + # + # @param bucket [String] S3 bucket name + # @param key [String] S3 object key + # @return [Hash] Result hash with :success or :error + def preflight_check(bucket, key) + s3_client = create_s3_client + + # Use head_object to check without downloading + response = s3_client.head_object(bucket: bucket, key: key) + + # Validate file size + if response.content_length > MAX_PDF_SIZE + return error_result("PDF file too large: #{response.content_length} bytes (max: #{MAX_PDF_SIZE})") end - rescue RetryHandler::RetryError => e - raise StandardError, e.message - end - def validate_url(url) - raise ArgumentError, 'URL cannot be nil or empty' if url.nil? || url.empty? - - uri = URI.parse(url) - return if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + # Validate content type if available + if response.content_type && !response.content_type.include?('pdf') + puts "WARNING: Content-Type is #{response.content_type}, expected PDF" + end - raise URI::InvalidURIError, 'URL must be HTTP or HTTPS' + puts "Preflight check passed: #{response.content_length} bytes, Content-Type: #{response.content_type}" + { success: true } + rescue Aws::S3::Errors::NotFound + error_result('Source PDF not found') + rescue Aws::S3::Errors::AccessDenied + error_result('Access denied during preflight check - verify credentials have s3:GetObject permission') + rescue Aws::Errors::ServiceError => e + error_result("Preflight check failed: #{e.message}") end - def fetch_with_redirects(uri, redirect_count = 0) - raise StandardError, "Too many redirects (max #{MAX_REDIRECTS})" if redirect_count >= MAX_REDIRECTS - - response = perform_http_request(uri) - - case response - when Net::HTTPSuccess - content_type = response['content-type'] || 'application/octet-stream' - [response.body, content_type] - when Net::HTTPRedirection - location = response['location'] - raise StandardError, 'Redirect without location header' unless location - - log_info("Following redirect to: #{sanitize_url(location)}") - new_uri = URI.parse(location) - fetch_with_redirects(new_uri, redirect_count + 1) + # Creates an S3 client using the provided credentials or default IAM role + # + # @return [Aws::S3::Client] Configured S3 client + def create_s3_client + if @credentials + Aws::S3::Client.new( + access_key_id: @credentials['accessKeyId'], + secret_access_key: @credentials['secretAccessKey'], + session_token: @credentials['sessionToken'] + ) else - raise StandardError, "HTTP #{response.code}: #{response.message}" + # Use Lambda's IAM role + Aws::S3::Client.new end end - def perform_http_request(uri) - Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| - http.read_timeout = TIMEOUT_SECONDS - http.open_timeout = TIMEOUT_SECONDS - - request = Net::HTTP::Get.new(uri) - request['User-Agent'] = 'PDF-Converter-Service/1.0' - - http.request(request) + def log_download_start(bucket, key) + if @credentials + sanitized = CredentialSanitizer.sanitize(@credentials) + puts "Downloading PDF from s3://#{bucket}/#{key} using credentials: #{sanitized['accessKeyId']}" + else + puts "Downloading PDF from s3://#{bucket}/#{key} using Lambda IAM role" end end + def log_download_success(size) + puts "PDF downloaded successfully: #{size} bytes" + end + def error_result(message) - log_error(message) + puts "ERROR: #{message}" { success: false, error: message } end - - def log_info(message) - @logger&.info(message) || puts("INFO: #{message}") - end - - def log_error(message) - @logger&.error(message) || puts("ERROR: #{message}") - end end diff --git a/pdf_converter/app/request_validator.rb b/pdf_converter/app/request_validator.rb index 947a579..33a8870 100644 --- a/pdf_converter/app/request_validator.rb +++ b/pdf_converter/app/request_validator.rb @@ -4,16 +4,19 @@ require_relative 'url_validator' # RequestValidator handles parsing and validation of incoming Lambda requests. -# It validates required fields, unique_id format, and URL formats for source, -# destination, and webhook URLs. +# It validates required fields, S3 bucket/key format, credentials, and unique_id format. class RequestValidator - # List of fields that must be present in the request body - REQUIRED_FIELDS = %w[source destination webhook unique_id].freeze - # Regex pattern for validating unique_id format # Only alphanumeric characters, underscores, and hyphens are allowed UNIQUE_ID_PATTERN = /\A[a-zA-Z0-9_-]+\z/ + # S3 bucket name validation (AWS rules) + # 3-63 characters, lowercase, numbers, dots, hyphens + BUCKET_NAME_PATTERN = /\A[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\z/ + + # S3 key validation - not empty and reasonable length + MAX_KEY_LENGTH = 1024 + def initialize @url_validator = UrlValidator.new end @@ -55,11 +58,9 @@ def parse_request_body(event, response_builder) # @param response_builder [ResponseBuilder] The response builder to use for error responses # @return [Hash, nil] Error response hash if validation fails, nil if valid def validate(body, response_builder) - # Check for missing required fields (including nil values) - missing_fields = REQUIRED_FIELDS.select { |field| body[field].nil? } - return response_builder.error_response(400, 'Missing required fields') unless missing_fields.empty? + # Validate unique_id first (required and format) + return response_builder.error_response(400, 'Missing required field: unique_id') if body['unique_id'].nil? - # Validate unique_id format to prevent path traversal attacks unless body['unique_id'].match?(UNIQUE_ID_PATTERN) return response_builder.error_response( 400, @@ -67,22 +68,78 @@ def validate(body, response_builder) ) end - # Validate source URL is a signed S3 URL for PDF - unless @url_validator.valid_s3_signed_url?(body['source']) - return response_builder.error_response(400, 'Invalid source URL: must be a signed S3 URL for PDF file') - end + # Validate source + source_error = validate_source(body['source']) + return response_builder.error_response(400, source_error) if source_error - # Validate destination URL is a signed S3 URL - unless @url_validator.valid_s3_destination_url?(body['destination']) - return response_builder.error_response(400, 'Invalid destination URL: must be a signed S3 URL') - end + # Validate destination + dest_error = validate_destination(body['destination']) + return response_builder.error_response(400, dest_error) if dest_error + + # Validate credentials + creds_error = validate_credentials(body['credentials']) + return response_builder.error_response(400, creds_error) if creds_error - # Validate webhook URL if provided - if body['webhook'] && !@url_validator.valid_url?(body['webhook']) + # Validate webhook URL if provided (optional) + if body['webhook'] && !body['webhook'].empty? && !@url_validator.valid_url?(body['webhook']) return response_builder.error_response(400, 'Invalid webhook URL format') end # All validations passed nil end + + private + + # Validates the source object + def validate_source(source) + return 'Missing required field: source' if source.nil? + return 'source must be an object' unless source.is_a?(Hash) + + return 'source.bucket is required' if source['bucket'].nil? || source['bucket'].empty? + return 'source.key is required' if source['key'].nil? || source['key'].empty? + + return 'Invalid source.bucket format' unless source['bucket'].match?(BUCKET_NAME_PATTERN) + return 'source.key is too long' if source['key'].length > MAX_KEY_LENGTH + return 'source.key must end with .pdf' unless source['key'].downcase.end_with?('.pdf') + + nil + end + + # Validates the destination object + def validate_destination(destination) + return 'Missing required field: destination' if destination.nil? + return 'destination must be an object' unless destination.is_a?(Hash) + + return 'destination.bucket is required' if destination['bucket'].nil? || destination['bucket'].empty? + return 'destination.prefix is required' if destination['prefix'].nil? + + return 'Invalid destination.bucket format' unless destination['bucket'].match?(BUCKET_NAME_PATTERN) + return 'destination.prefix is too long' if destination['prefix'].length > MAX_KEY_LENGTH + + nil + end + + # Validates the credentials object + def validate_credentials(credentials) + return 'Missing required field: credentials' if credentials.nil? + return 'credentials must be an object' unless credentials.is_a?(Hash) + + if credentials['accessKeyId'].nil? || credentials['accessKeyId'].empty? + return 'credentials.accessKeyId is required' + end + if credentials['secretAccessKey'].nil? || credentials['secretAccessKey'].empty? + return 'credentials.secretAccessKey is required' + end + if credentials['sessionToken'].nil? || credentials['sessionToken'].empty? + return 'credentials.sessionToken is required' + end + + # Basic format validation for access key ID (should start with ASIA for temp creds) + unless credentials['accessKeyId'].start_with?('ASIA', 'AKIA') + return 'Invalid credentials.accessKeyId format' + end + + nil + end end diff --git a/pdf_converter/app/url_validator.rb b/pdf_converter/app/url_validator.rb deleted file mode 100644 index b650186..0000000 --- a/pdf_converter/app/url_validator.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -require 'uri' -require_relative '../lib/s3_url_parser' - -# UrlValidator provides enhanced URL validation specifically for S3 signed URLs -# and general URL validation with additional S3-specific checks -class UrlValidator - REQUIRED_S3_SIGNATURE_PARAMS = ['X-Amz-Algorithm'].freeze - PDF_EXTENSIONS = ['.pdf'].freeze - - # Validates if a URL is a properly signed S3 URL for destination uploads - # @param url [String] The URL to validate - # @return [Boolean] true if URL is a valid signed S3 URL for uploads - def valid_s3_destination_url?(url) - validate_s3_url(url, require_pdf: false) - end - - # Validates if a URL is a properly signed S3 URL for PDF files - # @param url [String] The URL to validate - # @return [Boolean] true if URL is a valid signed S3 URL for PDF - def valid_s3_signed_url?(url) - validate_s3_url(url, require_pdf: true) - end - - # Basic URL validation for HTTP/HTTPS URLs - # @param url [String] The URL to validate - # @return [Boolean] true if URL is valid HTTP/HTTPS - def valid_url?(url) - return false unless valid_basic_url?(url) - - uri = URI.parse(url) - uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - rescue URI::InvalidURIError - false - end - - # Extracts S3 bucket, key, and region information from S3 URL - # @param url [String] The S3 URL to parse - # @return [Hash, nil] Hash with :bucket, :key, :region or nil if invalid - def extract_s3_info(url) - return nil unless valid_s3_signed_url?(url) - - S3UrlParser.extract_s3_info(url) - end - - private - - # Core validation logic shared by both destination and signed URL validation - # @param url [String] The URL to validate - # @param require_pdf [Boolean] Whether to check for PDF extension - # @return [Boolean] true if URL passes all validation checks - def validate_s3_url(url, require_pdf:) - return false unless valid_basic_url?(url) - - uri = URI.parse(url) - valid_scheme?(uri) && - valid_s3_host?(uri.host) && - valid_pdf_requirement?(uri.path, require_pdf) && - s3_signature_params?(uri.query) - rescue URI::InvalidURIError - false - end - - # Validates that URL is not nil or empty - # @param url [String] The URL to check - # @return [Boolean] true if URL has content - def valid_basic_url?(url) - !url.nil? && !url.empty? - end - - # Validates PDF requirement is met - # @param path [String] The URL path - # @param require_pdf [Boolean] Whether PDF is required - # @return [Boolean] true if PDF requirement is satisfied - def valid_pdf_requirement?(path, require_pdf) - !require_pdf || pdf_file?(path) - end - - # Validates URL scheme (HTTPS required, except HTTP for LocalStack) - # @param uri [URI] Parsed URI object - # @return [Boolean] true if scheme is valid - def valid_scheme?(uri) - scheme = uri.scheme - scheme == 'https' || (scheme == 'http' && S3UrlParser.localstack_hostname?(uri.host)) - end - - # Validates that hostname is either S3 or LocalStack - # @param hostname [String] The hostname to validate - # @return [Boolean] true if hostname is valid - def valid_s3_host?(hostname) - S3UrlParser.s3_hostname?(hostname) || S3UrlParser.localstack_hostname?(hostname) - end - - def pdf_file?(path) - return false unless path && !path.empty? - - # Extract filename from path - filename = File.basename(path) - PDF_EXTENSIONS.any? { |ext| filename.downcase.end_with?(ext) } - end - - # Checks if query string contains required S3 signature parameters - # @param query_string [String] The URL query string - # @return [Boolean] true if all required signature params present - def s3_signature_params?(query_string) - return false unless query_string && !query_string.empty? - - query_params = URI.decode_www_form(query_string).to_h - REQUIRED_S3_SIGNATURE_PARAMS.all? { |param| query_params.key?(param) } - end -end diff --git a/pdf_converter/lib/credential_sanitizer.rb b/pdf_converter/lib/credential_sanitizer.rb new file mode 100644 index 0000000..ade3008 --- /dev/null +++ b/pdf_converter/lib/credential_sanitizer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# CredentialSanitizer provides utilities to sanitize AWS credentials for logging. +# This prevents accidental exposure of sensitive information in logs while still +# providing useful debugging information. +module CredentialSanitizer + # Sanitizes AWS STS credentials for safe logging. + # Shows first and last 4 characters of access key and session token, + # completely redacts secret access key. + # + # @param credentials [Hash, nil] Hash containing accessKeyId, secretAccessKey, sessionToken + # @return [Hash, nil] Sanitized credentials hash safe for logging + def self.sanitize(credentials) + return nil if credentials.nil? + return '***INVALID_FORMAT***' unless credentials.is_a?(Hash) + + { + 'accessKeyId' => mask_credential(credentials['accessKeyId']), + 'secretAccessKey' => '***REDACTED***', + 'sessionToken' => mask_credential(credentials['sessionToken']) + } + end + + # Masks a credential by showing only first and last 4 characters. + # For credentials shorter than 12 characters, completely redacts. + # + # @param value [String, nil] The credential value to mask + # @return [String] Masked credential string + def self.mask_credential(value) + return '***MISSING***' if value.nil? + return '***EMPTY***' if value.empty? + return '***REDACTED***' if value.length < 12 + + "#{value[0..3]}...#{value[-4..]}" + end + + private_class_method :mask_credential +end diff --git a/pdf_converter/lib/retry_handler.rb b/pdf_converter/lib/retry_handler.rb deleted file mode 100644 index 2467202..0000000 --- a/pdf_converter/lib/retry_handler.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require 'timeout' -require 'socket' -require 'openssl' - -# RetryHandler provides reusable retry logic with exponential backoff -# for handling transient failures in network operations -module RetryHandler - # Default configuration - DEFAULT_MAX_ATTEMPTS = 3 - DEFAULT_RETRY_DELAY_BASE = 1 # seconds - RETRYABLE_HTTP_CODES = [500, 502, 503, 504].freeze - - # Retryable exception types - RETRYABLE_EXCEPTIONS = [ - Timeout::Error, - Errno::ECONNREFUSED, - SocketError, - OpenSSL::SSL::SSLError - ].freeze - - # Non-retryable exception types that should fail immediately - NON_RETRYABLE_EXCEPTIONS = [ - NoMemoryError - ].freeze - - class RetryError < StandardError; end - - # Executes a block with retry logic - # @param max_attempts [Integer] Maximum number of retry attempts - # @param delay_base [Integer] Base delay in seconds for exponential backoff - # @param logger [Logger, nil] Optional logger for retry messages - # @yield The block to execute with retry logic - # @return The result of the block execution - # @raise RetryError if all retry attempts are exhausted - def self.with_retry(max_attempts: DEFAULT_MAX_ATTEMPTS, delay_base: DEFAULT_RETRY_DELAY_BASE, logger: nil) - attempt = 1 - last_error = nil - - while attempt <= max_attempts - begin - return yield(attempt) - rescue *NON_RETRYABLE_EXCEPTIONS => e - # Don't retry non-retryable errors, fail immediately - raise e - rescue StandardError => e - last_error = e - - # Check if we should retry this error - raise e unless retryable_error?(e) - - # Check if we have attempts remaining - raise RetryError, "#{e.message} after #{max_attempts} attempts" if attempt >= max_attempts - - # Log the retry attempt - log_retry(logger, attempt, e.message) - - # Wait before retrying with exponential backoff - wait_before_retry(attempt, delay_base) - - attempt += 1 - end - end - - # This should never be reached, but included for safety - raise RetryError, "#{last_error&.message || 'Unknown error'} after #{max_attempts} attempts" - end - - # Determines if an error is retryable - # @param error [StandardError] The error to check - # @return [Boolean] true if the error should trigger a retry - def self.retryable_error?(error) - # Check if it's a retryable exception type - return true if RETRYABLE_EXCEPTIONS.any? { |exception_class| error.is_a?(exception_class) } - - # Check for retryable HTTP status codes - if error.message.match(/HTTP (\d+):/) - status_code = ::Regexp.last_match(1).to_i - return RETRYABLE_HTTP_CODES.include?(status_code) - end - - false - end - - # Waits before retrying with exponential backoff - # @param attempt [Integer] Current attempt number - # @param delay_base [Integer] Base delay in seconds - def self.wait_before_retry(attempt, delay_base) - delay = delay_base * (2**(attempt - 1)) # Exponential backoff: 1s, 2s, 4s, 8s... - sleep(delay) - end - - # Logs retry attempt information - # @param logger [Logger, nil] Logger instance or nil - # @param attempt [Integer] Current attempt number - # @param error_message [String] Error message - def self.log_retry(logger, attempt, error_message) - message = "Retrying attempt #{attempt + 1} after error: #{error_message}" - - if logger - logger.info(message) - else - puts "INFO: #{message}" - end - end -end diff --git a/pdf_converter/lib/s3_url_parser.rb b/pdf_converter/lib/s3_url_parser.rb deleted file mode 100644 index 4ec3a29..0000000 --- a/pdf_converter/lib/s3_url_parser.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -require 'uri' - -# S3UrlParser handles parsing and extraction of information from S3 URLs -# supporting both path-style and virtual-hosted-style S3 URLs -class S3UrlParser - S3_HOSTNAME_PATTERNS = [ - /\As3\.amazonaws\.com\z/, # s3.amazonaws.com (US Standard) - /\As3\.([a-z0-9-]+)\.amazonaws\.com\z/, # s3.region.amazonaws.com - /\A([a-z0-9.-]+)\.s3\.amazonaws\.com\z/, # bucket.s3.amazonaws.com - /\A([a-z0-9.-]+)\.s3\.([a-z0-9-]+)\.amazonaws\.com\z/ # bucket.s3.region.amazonaws.com - ].freeze - - # Checks if hostname matches S3 patterns - # @param hostname [String] The hostname to check - # @return [Boolean] true if hostname matches S3 patterns - def self.s3_hostname?(hostname) - return false unless hostname - - S3_HOSTNAME_PATTERNS.any? { |pattern| hostname.match?(pattern) } - end - - # Checks if hostname is LocalStack (for local testing) - # @param hostname [String] The hostname to check - # @return [Boolean] true if hostname is LocalStack - def self.localstack_hostname?(hostname) - return false unless hostname - - # Allow localhost and 127.0.0.1 for LocalStack testing - hostname == 'localhost' || hostname == '127.0.0.1' || hostname.start_with?('localstack') - end - - # Checks if URL uses path-style S3 format - # @param hostname [String] The hostname to check - # @return [Boolean] true if path-style S3 - def self.path_style_s3?(hostname) - hostname&.match?(/\As3\./) || hostname == 's3.amazonaws.com' - end - - # Checks if URL uses virtual-hosted-style S3 format - # @param hostname [String] The hostname to check - # @return [Boolean] true if virtual-hosted-style S3 - def self.virtual_hosted_style_s3?(hostname) - return false unless hostname - - hostname.match?(/\.s3\./) - end - - # Extracts S3 bucket, key, and region information from S3 URL - # @param url [String] The S3 URL to parse - # @return [Hash, nil] Hash with :bucket, :key, :region or nil if invalid - def self.extract_s3_info(url) - return nil unless url && !url.empty? - - uri = URI.parse(url) - extract_by_style(uri) - rescue StandardError - nil - end - - # Determines URL style and extracts info accordingly - # @param uri [URI] Parsed URI object - # @return [Hash, nil] Extracted S3 info or nil - def self.extract_by_style(uri) - host = uri.host - return extract_path_style_info(uri) if path_style_s3?(host) - return extract_virtual_hosted_info(uri) if virtual_hosted_style_s3?(host) - - nil - end - private_class_method :extract_by_style - - # Extracts region from S3 hostname - # @param hostname [String] The S3 hostname - # @return [String, nil] The region or nil if not found - def self.extract_region_from_hostname(hostname) - # Extract region from hostnames like s3.us-west-2.amazonaws.com - match = hostname.match(/s3\.([a-z0-9-]+)\.amazonaws\.com/) || - hostname.match(/\.s3\.([a-z0-9-]+)\.amazonaws\.com/) - match ? match[1] : nil - end - - # Validates path parts have required structure - # @param path_parts [Array] Split path components - # @return [Boolean] true if path parts are valid - def self.valid_path_parts?(path_parts) - path_parts.length >= 3 && !path_parts[1].empty? - end - private_class_method :valid_path_parts? - - # Extracts info from path-style S3 URL - # @param uri [URI] Parsed URI object - # @return [Hash, nil] Hash with :bucket, :key, :region or nil - def self.extract_path_style_info(uri) - # For path-style: https://s3.region.amazonaws.com/bucket/key - path_parts = uri.path.split('/', 3) - return nil unless valid_path_parts?(path_parts) - - { - bucket: path_parts[1], - key: path_parts[2], - region: extract_region_from_hostname(uri.host) || 'us-east-1' - } - end - private_class_method :extract_path_style_info - - # Extracts info from virtual-hosted-style S3 URL - # @param uri [URI] Parsed URI object - # @return [Hash, nil] Hash with :bucket, :key, :region or nil - def self.extract_virtual_hosted_info(uri) - # For virtual-hosted-style: https://bucket.s3.region.amazonaws.com/key - host = uri.host - hostname_parts = host.split('.') - return nil if hostname_parts.length < 4 - - { - bucket: hostname_parts[0], - key: normalize_path(uri.path), - region: extract_region_from_hostname(host) || 'us-east-1' - } - end - - # Removes leading slash from path - # @param path [String] The URL path - # @return [String] Path without leading slash - def self.normalize_path(path) - path.start_with?('/') ? path[1..] : path - end - private_class_method :normalize_path - private_class_method :extract_virtual_hosted_info -end diff --git a/pdf_converter/lib/url_utils.rb b/pdf_converter/lib/url_utils.rb deleted file mode 100644 index c223623..0000000 --- a/pdf_converter/lib/url_utils.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'uri' - -# UrlUtils provides utility methods for URL operations. -# This module contains shared functionality used across multiple classes. -module UrlUtils - # Sanitizes URL for logging by removing query parameters that might contain secrets. - # - # @param url [String] The URL to sanitize - # @return [String] Sanitized URL with query parameters hidden - def sanitize_url(url) - uri = URI.parse(url) - "#{uri.scheme}://#{uri.host}#{uri.path}[QUERY_PARAMS_HIDDEN]" - rescue StandardError - '[URL_PARSE_ERROR]' - end - - # Removes query parameters from URLs. - # - # @param urls [Array] URLs with query parameters - # @return [Array] URLs without query parameters - def self.strip_query_params(urls) - urls.map { |url| url.split('?').first } - end -end diff --git a/pdf_converter/spec/.rspec_status b/pdf_converter/spec/.rspec_status index 98befce..789840d 100644 --- a/pdf_converter/spec/.rspec_status +++ b/pdf_converter/spec/.rspec_status @@ -1,555 +1,555 @@ example_id | status | run_time | -------------------------------------------------------- | ------ | --------------- | -./spec/app/image_uploader_spec.rb[1:1:1] | passed | 0.00005 seconds | -./spec/app/image_uploader_spec.rb[1:2:1] | passed | 0.00005 seconds | -./spec/app/image_uploader_spec.rb[1:3:1:1] | passed | 0.00041 seconds | -./spec/app/image_uploader_spec.rb[1:3:1:2] | passed | 0.00042 seconds | -./spec/app/image_uploader_spec.rb[1:3:1:3] | passed | 0.00025 seconds | -./spec/app/image_uploader_spec.rb[1:3:1:4] | passed | 0.00026 seconds | -./spec/app/image_uploader_spec.rb[1:3:1:5] | passed | 0.00031 seconds | -./spec/app/image_uploader_spec.rb[1:3:1:6] | passed | 0.00034 seconds | -./spec/app/image_uploader_spec.rb[1:3:1:7] | passed | 0.00033 seconds | -./spec/app/image_uploader_spec.rb[1:3:2:1] | passed | 0.00026 seconds | -./spec/app/image_uploader_spec.rb[1:3:3:1] | passed | 0.00028 seconds | -./spec/app/image_uploader_spec.rb[1:3:4:1] | passed | 0.00034 seconds | -./spec/app/image_uploader_spec.rb[1:3:5:1] | passed | 0.00005 seconds | -./spec/app/image_uploader_spec.rb[1:3:6:1] | passed | 0.00005 seconds | -./spec/app/image_uploader_spec.rb[1:3:7:1] | passed | 0.00006 seconds | -./spec/app/image_uploader_spec.rb[1:3:8:1] | passed | 0.00037 seconds | -./spec/app/image_uploader_spec.rb[1:3:9:1] | passed | 0.00012 seconds | -./spec/app/image_uploader_spec.rb[1:3:10:1] | passed | 0.0003 seconds | -./spec/app/image_uploader_spec.rb[1:3:10:2] | passed | 0.00029 seconds | -./spec/app/image_uploader_spec.rb[1:3:11:1] | passed | 0.00039 seconds | -./spec/app/image_uploader_spec.rb[1:3:12:1] | passed | 3.01 seconds | -./spec/app/image_uploader_spec.rb[1:3:13:1] | passed | 3.01 seconds | -./spec/app/image_uploader_spec.rb[1:3:14:1] | passed | 0.00071 seconds | -./spec/app/image_uploader_spec.rb[1:3:15:1] | passed | 0.00058 seconds | -./spec/app/image_uploader_spec.rb[1:3:16:1] | passed | 0.00057 seconds | -./spec/app/image_uploader_spec.rb[1:4:1:1] | passed | 0.00093 seconds | -./spec/app/image_uploader_spec.rb[1:4:1:2] | passed | 0.00155 seconds | -./spec/app/image_uploader_spec.rb[1:4:1:3] | passed | 0.0009 seconds | -./spec/app/image_uploader_spec.rb[1:4:1:4] | passed | 0.00265 seconds | -./spec/app/image_uploader_spec.rb[1:4:2:1] | passed | 0.00016 seconds | -./spec/app/image_uploader_spec.rb[1:4:3:1] | passed | 0.00092 seconds | -./spec/app/image_uploader_spec.rb[1:4:3:2] | passed | 0.00103 seconds | -./spec/app/image_uploader_spec.rb[1:4:4:1] | passed | 3.01 seconds | -./spec/app/image_uploader_spec.rb[1:4:4:2] | passed | 3.01 seconds | -./spec/app/image_uploader_spec.rb[1:5:1:1] | passed | 0.00147 seconds | -./spec/app/image_uploader_spec.rb[1:5:1:2] | passed | 0.00142 seconds | -./spec/app/image_uploader_spec.rb[1:5:1:3] | passed | 0.00797 seconds | -./spec/app/image_uploader_spec.rb[1:5:1:4] | passed | 0.00102 seconds | -./spec/app/image_uploader_spec.rb[1:5:2:1] | passed | 0.00085 seconds | -./spec/app/image_uploader_spec.rb[1:5:3:1] | passed | 0.00134 seconds | -./spec/app/image_uploader_spec.rb[1:5:4:1] | passed | 0.00093 seconds | -./spec/app/image_uploader_spec.rb[1:5:4:2] | passed | 0.00087 seconds | -./spec/app/image_uploader_spec.rb[1:5:5:1] | passed | 0.00049 seconds | -./spec/app/image_uploader_spec.rb[1:5:6:1] | passed | 0.00029 seconds | -./spec/app/image_uploader_spec.rb[1:6:1:1] | passed | 0.00007 seconds | -./spec/app/image_uploader_spec.rb[1:6:2:1] | passed | 0.00006 seconds | -./spec/app/image_uploader_spec.rb[1:6:3:1] | passed | 0.00005 seconds | -./spec/app/image_uploader_spec.rb[1:6:4:1] | passed | 0.00015 seconds | -./spec/app/image_uploader_spec.rb[1:6:5:1] | passed | 0.00009 seconds | -./spec/app/image_uploader_spec.rb[1:7:1:1] | passed | 0.00132 seconds | -./spec/app/image_uploader_spec.rb[1:7:2:1] | passed | 3.01 seconds | -./spec/app/image_uploader_spec.rb[1:8:1:1] | passed | 0.00084 seconds | -./spec/app/image_uploader_spec.rb[1:8:2:1] | passed | 0.00024 seconds | -./spec/app/image_uploader_spec.rb[1:8:3:1] | passed | 0.0011 seconds | -./spec/app/image_uploader_spec.rb[1:8:4:1] | passed | 0.00042 seconds | -./spec/app/image_uploader_spec.rb[1:8:5:1] | passed | 0.00053 seconds | -./spec/app/image_uploader_spec.rb[1:8:6:1] | passed | 0.00035 seconds | -./spec/app/image_uploader_spec.rb[1:8:7:1] | passed | 0.00054 seconds | -./spec/app/image_uploader_spec.rb[1:9:1:1] | passed | 0.0001 seconds | -./spec/app/image_uploader_spec.rb[1:9:2:1] | passed | 0.00147 seconds | -./spec/app/image_uploader_spec.rb[1:10:1] | passed | 0.00072 seconds | -./spec/app/image_uploader_spec.rb[1:10:2] | passed | 0.00046 seconds | -./spec/app/image_uploader_spec.rb[1:10:3] | passed | 0.00036 seconds | -./spec/app/image_uploader_spec.rb[1:11:1:1] | passed | 0.00005 seconds | -./spec/app/image_uploader_spec.rb[1:11:1:2] | passed | 0.00006 seconds | -./spec/app/image_uploader_spec.rb[1:11:1:3] | passed | 0.00014 seconds | -./spec/app/image_uploader_spec.rb[1:11:2:1] | passed | 0.00025 seconds | -./spec/app/image_uploader_spec.rb[1:11:2:2] | passed | 0.00011 seconds | -./spec/app/image_uploader_spec.rb[1:11:2:3] | passed | 0.00014 seconds | -./spec/app/image_uploader_spec.rb[1:11:3:1] | passed | 0.00004 seconds | -./spec/app/image_uploader_spec.rb[1:12:1] | passed | 0.00008 seconds | -./spec/app/image_uploader_spec.rb[1:12:2] | passed | 0.00008 seconds | -./spec/app/image_uploader_spec.rb[1:13:1] | passed | 0.00009 seconds | -./spec/app/image_uploader_spec.rb[1:14:1] | passed | 0.00009 seconds | -./spec/app/jwt_authenticator_spec.rb[1:1:1] | passed | 0.00018 seconds | -./spec/app/jwt_authenticator_spec.rb[1:1:2] | passed | 0.00016 seconds | -./spec/app/jwt_authenticator_spec.rb[1:1:3:1] | passed | 0.00022 seconds | -./spec/app/jwt_authenticator_spec.rb[1:1:4:1] | passed | 0.00145 seconds | -./spec/app/jwt_authenticator_spec.rb[1:1:5:1] | passed | 0.00021 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:1:1] | passed | 0.0002 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:1:2] | passed | 0.0002 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:2:1] | passed | 0.00075 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:3:1] | passed | 0.00017 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:3:2] | passed | 0.00023 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:4:1] | passed | 0.00016 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:4:2] | passed | 0.00016 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:5:1] | passed | 0.0002 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:5:2] | passed | 0.0002 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:6:1] | passed | 0.00022 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:6:2] | passed | 0.00034 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:7:1] | passed | 0.00031 seconds | +./spec/app/image_uploader_spec.rb[1:1:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:2:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:3:1:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:1:2] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:1:3] | failed | 0.00006 seconds | +./spec/app/image_uploader_spec.rb[1:3:1:4] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:1:5] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:1:6] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:1:7] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:2:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:3:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:4:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:5:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:3:6:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:3:7:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:3:8:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:3:9:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:3:10:1] | failed | 0.00006 seconds | +./spec/app/image_uploader_spec.rb[1:3:10:2] | failed | 0.00006 seconds | +./spec/app/image_uploader_spec.rb[1:3:11:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:12:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:13:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:3:14:1] | failed | 0.00006 seconds | +./spec/app/image_uploader_spec.rb[1:3:15:1] | failed | 0.00011 seconds | +./spec/app/image_uploader_spec.rb[1:3:16:1] | failed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:4:1:1] | failed | 0.00007 seconds | +./spec/app/image_uploader_spec.rb[1:4:1:2] | failed | 0.00007 seconds | +./spec/app/image_uploader_spec.rb[1:4:1:3] | failed | 0.00007 seconds | +./spec/app/image_uploader_spec.rb[1:4:1:4] | failed | 0.00008 seconds | +./spec/app/image_uploader_spec.rb[1:4:2:1] | failed | 0.00017 seconds | +./spec/app/image_uploader_spec.rb[1:4:3:1] | failed | 0.00027 seconds | +./spec/app/image_uploader_spec.rb[1:4:3:2] | failed | 0.00009 seconds | +./spec/app/image_uploader_spec.rb[1:4:4:1] | failed | 0.00008 seconds | +./spec/app/image_uploader_spec.rb[1:4:4:2] | failed | 0.00007 seconds | +./spec/app/image_uploader_spec.rb[1:5:1:1] | failed | 0.00022 seconds | +./spec/app/image_uploader_spec.rb[1:5:1:2] | failed | 0.00022 seconds | +./spec/app/image_uploader_spec.rb[1:5:1:3] | failed | 0.00024 seconds | +./spec/app/image_uploader_spec.rb[1:5:1:4] | failed | 0.00026 seconds | +./spec/app/image_uploader_spec.rb[1:5:2:1] | failed | 0.00048 seconds | +./spec/app/image_uploader_spec.rb[1:5:3:1] | failed | 0.00023 seconds | +./spec/app/image_uploader_spec.rb[1:5:4:1] | failed | 0.00024 seconds | +./spec/app/image_uploader_spec.rb[1:5:4:2] | failed | 0.00023 seconds | +./spec/app/image_uploader_spec.rb[1:5:5:1] | failed | 0.00022 seconds | +./spec/app/image_uploader_spec.rb[1:5:6:1] | failed | 0.00023 seconds | +./spec/app/image_uploader_spec.rb[1:6:1:1] | failed | 0.00017 seconds | +./spec/app/image_uploader_spec.rb[1:6:2:1] | failed | 0.00016 seconds | +./spec/app/image_uploader_spec.rb[1:6:3:1] | failed | 0.00015 seconds | +./spec/app/image_uploader_spec.rb[1:6:4:1] | failed | 0.00026 seconds | +./spec/app/image_uploader_spec.rb[1:6:5:1] | failed | 0.00021 seconds | +./spec/app/image_uploader_spec.rb[1:7:1:1] | failed | 0.00024 seconds | +./spec/app/image_uploader_spec.rb[1:7:2:1] | failed | 0.00114 seconds | +./spec/app/image_uploader_spec.rb[1:8:1:1] | failed | 0.00008 seconds | +./spec/app/image_uploader_spec.rb[1:8:2:1] | failed | 0.00006 seconds | +./spec/app/image_uploader_spec.rb[1:8:3:1] | failed | 0.00006 seconds | +./spec/app/image_uploader_spec.rb[1:8:4:1] | failed | 0.00019 seconds | +./spec/app/image_uploader_spec.rb[1:8:5:1] | failed | 0.00022 seconds | +./spec/app/image_uploader_spec.rb[1:8:6:1] | failed | 0.00011 seconds | +./spec/app/image_uploader_spec.rb[1:8:7:1] | failed | 0.00012 seconds | +./spec/app/image_uploader_spec.rb[1:9:1:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:9:2:1] | failed | 0.00002 seconds | +./spec/app/image_uploader_spec.rb[1:10:1] | failed | 0.00029 seconds | +./spec/app/image_uploader_spec.rb[1:10:2] | failed | 0.00039 seconds | +./spec/app/image_uploader_spec.rb[1:10:3] | failed | 0.00062 seconds | +./spec/app/image_uploader_spec.rb[1:11:1:1] | failed | 0.00002 seconds | +./spec/app/image_uploader_spec.rb[1:11:1:2] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:11:1:3] | failed | 0.00002 seconds | +./spec/app/image_uploader_spec.rb[1:11:2:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:11:2:2] | failed | 0.00004 seconds | +./spec/app/image_uploader_spec.rb[1:11:2:3] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:11:3:1] | failed | 0.00003 seconds | +./spec/app/image_uploader_spec.rb[1:12:1] | passed | 0.00004 seconds | +./spec/app/image_uploader_spec.rb[1:12:2] | passed | 0.00005 seconds | +./spec/app/image_uploader_spec.rb[1:13:1] | failed | 0.00006 seconds | +./spec/app/image_uploader_spec.rb[1:14:1] | failed | 0.00004 seconds | +./spec/app/jwt_authenticator_spec.rb[1:1:1] | passed | 0.00023 seconds | +./spec/app/jwt_authenticator_spec.rb[1:1:2] | passed | 0.00017 seconds | +./spec/app/jwt_authenticator_spec.rb[1:1:3:1] | passed | 0.00021 seconds | +./spec/app/jwt_authenticator_spec.rb[1:1:4:1] | passed | 0.00147 seconds | +./spec/app/jwt_authenticator_spec.rb[1:1:5:1] | passed | 0.00026 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:1:1] | passed | 0.00026 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:1:2] | passed | 0.00023 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:2:1] | passed | 0.00021 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:3:1] | passed | 0.00018 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:3:2] | passed | 0.00018 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:4:1] | passed | 0.00026 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:4:2] | passed | 0.00018 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:5:1] | passed | 0.00028 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:5:2] | passed | 0.00021 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:6:1] | passed | 0.00027 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:6:2] | passed | 0.00022 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:7:1] | passed | 0.00018 seconds | ./spec/app/jwt_authenticator_spec.rb[1:2:7:2] | passed | 0.00018 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:8:1] | passed | 0.0003 seconds | -./spec/app/jwt_authenticator_spec.rb[1:2:8:2] | passed | 0.00022 seconds | -./spec/app/jwt_authenticator_spec.rb[1:3:1:1] | passed | 0.00017 seconds | -./spec/app/jwt_authenticator_spec.rb[1:3:2:1] | passed | 0.00026 seconds | -./spec/app/jwt_authenticator_spec.rb[1:3:3:1] | passed | 0.00016 seconds | -./spec/app/jwt_authenticator_spec.rb[1:3:4:1] | passed | 0.00016 seconds | -./spec/app/jwt_authenticator_spec.rb[1:3:5:1] | passed | 0.00018 seconds | -./spec/app/jwt_authenticator_spec.rb[1:3:6:1] | passed | 0.00016 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:8:1] | passed | 0.00023 seconds | +./spec/app/jwt_authenticator_spec.rb[1:2:8:2] | passed | 0.00024 seconds | +./spec/app/jwt_authenticator_spec.rb[1:3:1:1] | passed | 0.00016 seconds | +./spec/app/jwt_authenticator_spec.rb[1:3:2:1] | passed | 0.00023 seconds | +./spec/app/jwt_authenticator_spec.rb[1:3:3:1] | passed | 0.00022 seconds | +./spec/app/jwt_authenticator_spec.rb[1:3:4:1] | passed | 0.00018 seconds | +./spec/app/jwt_authenticator_spec.rb[1:3:5:1] | passed | 0.00017 seconds | +./spec/app/jwt_authenticator_spec.rb[1:3:6:1] | passed | 0.00017 seconds | ./spec/app/jwt_authenticator_spec.rb[1:3:7:1] | passed | 0.00017 seconds | ./spec/app/jwt_authenticator_spec.rb[1:3:8:1] | passed | 0.00016 seconds | -./spec/app/jwt_authenticator_spec.rb[1:4:1:1] | passed | 0.00024 seconds | -./spec/app/jwt_authenticator_spec.rb[1:4:2:1] | passed | 0.00021 seconds | -./spec/app/jwt_authenticator_spec.rb[1:4:3:1] | passed | 0.00017 seconds | -./spec/app/jwt_authenticator_spec.rb[1:4:4:1] | passed | 0.0003 seconds | -./spec/app/jwt_authenticator_spec.rb[1:4:5:1] | passed | 0.00023 seconds | -./spec/app/jwt_authenticator_spec.rb[1:4:6:1] | passed | 0.00018 seconds | +./spec/app/jwt_authenticator_spec.rb[1:4:1:1] | passed | 0.00022 seconds | +./spec/app/jwt_authenticator_spec.rb[1:4:2:1] | passed | 0.00017 seconds | +./spec/app/jwt_authenticator_spec.rb[1:4:3:1] | passed | 0.00016 seconds | +./spec/app/jwt_authenticator_spec.rb[1:4:4:1] | passed | 0.00019 seconds | +./spec/app/jwt_authenticator_spec.rb[1:4:5:1] | passed | 0.00047 seconds | +./spec/app/jwt_authenticator_spec.rb[1:4:6:1] | passed | 0.00032 seconds | ./spec/app/jwt_authenticator_spec.rb[1:4:7:1] | passed | 0.00024 seconds | ./spec/app/jwt_authenticator_spec.rb[1:5:1:1] | passed | 0.0002 seconds | -./spec/app/jwt_authenticator_spec.rb[1:5:2:1] | passed | 0.0002 seconds | -./spec/app/jwt_authenticator_spec.rb[1:5:3:1] | passed | 0.00019 seconds | -./spec/app/jwt_authenticator_spec.rb[1:5:4:1] | passed | 0.00022 seconds | -./spec/app/jwt_authenticator_spec.rb[1:6:1] | passed | 0.00019 seconds | -./spec/app/jwt_authenticator_spec.rb[1:7:1] | passed | 0.00017 seconds | -./spec/app/jwt_authenticator_spec.rb[1:7:2] | passed | 0.00021 seconds | -./spec/app/jwt_authenticator_spec.rb[1:8:1] | passed | 0.00014 seconds | -./spec/app/pdf_converter_spec.rb[1:1:1] | passed | 0.00114 seconds | -./spec/app/pdf_converter_spec.rb[1:1:2] | passed | 0.00021 seconds | -./spec/app/pdf_converter_spec.rb[1:1:3] | passed | 0.0002 seconds | -./spec/app/pdf_converter_spec.rb[1:2:1:1] | passed | 0.00018 seconds | -./spec/app/pdf_converter_spec.rb[1:2:1:2] | passed | 0.00018 seconds | -./spec/app/pdf_converter_spec.rb[1:2:1:3] | passed | 0.00017 seconds | -./spec/app/pdf_converter_spec.rb[1:2:2:1] | passed | 0.00019 seconds | -./spec/app/pdf_converter_spec.rb[1:2:2:2] | passed | 0.00019 seconds | -./spec/app/pdf_converter_spec.rb[1:2:2:3] | passed | 0.00032 seconds | -./spec/app/pdf_converter_spec.rb[1:2:3:1] | passed | 0.00021 seconds | -./spec/app/pdf_converter_spec.rb[1:2:4:1] | passed | 0.00021 seconds | -./spec/app/pdf_converter_spec.rb[1:3:1:1] | passed | 0.00065 seconds | -./spec/app/pdf_converter_spec.rb[1:3:1:2] | passed | 0.00048 seconds | -./spec/app/pdf_converter_spec.rb[1:3:1:3] | passed | 0.0006 seconds | -./spec/app/pdf_converter_spec.rb[1:3:1:4] | passed | 0.00057 seconds | -./spec/app/pdf_converter_spec.rb[1:3:1:5] | passed | 0.00096 seconds | -./spec/app/pdf_converter_spec.rb[1:3:2:1] | passed | 0.00054 seconds | -./spec/app/pdf_converter_spec.rb[1:3:2:2] | passed | 0.00074 seconds | -./spec/app/pdf_converter_spec.rb[1:3:3:1] | passed | 0.00076 seconds | -./spec/app/pdf_converter_spec.rb[1:3:3:2] | passed | 0.00066 seconds | +./spec/app/jwt_authenticator_spec.rb[1:5:2:1] | passed | 0.00028 seconds | +./spec/app/jwt_authenticator_spec.rb[1:5:3:1] | passed | 0.00021 seconds | +./spec/app/jwt_authenticator_spec.rb[1:5:4:1] | passed | 0.00021 seconds | +./spec/app/jwt_authenticator_spec.rb[1:6:1] | passed | 0.00017 seconds | +./spec/app/jwt_authenticator_spec.rb[1:7:1] | passed | 0.00018 seconds | +./spec/app/jwt_authenticator_spec.rb[1:7:2] | passed | 0.00022 seconds | +./spec/app/jwt_authenticator_spec.rb[1:8:1] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:1:1] | passed | 0.00069 seconds | +./spec/app/pdf_converter_spec.rb[1:1:2] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:1:3] | passed | 0.00012 seconds | +./spec/app/pdf_converter_spec.rb[1:2:1:1] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:2:1:2] | passed | 0.00012 seconds | +./spec/app/pdf_converter_spec.rb[1:2:1:3] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:2:2:1] | passed | 0.00015 seconds | +./spec/app/pdf_converter_spec.rb[1:2:2:2] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:2:2:3] | passed | 0.00012 seconds | +./spec/app/pdf_converter_spec.rb[1:2:3:1] | passed | 0.00015 seconds | +./spec/app/pdf_converter_spec.rb[1:2:4:1] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:3:1:1] | passed | 0.00051 seconds | +./spec/app/pdf_converter_spec.rb[1:3:1:2] | passed | 0.00049 seconds | +./spec/app/pdf_converter_spec.rb[1:3:1:3] | passed | 0.00046 seconds | +./spec/app/pdf_converter_spec.rb[1:3:1:4] | passed | 0.00048 seconds | +./spec/app/pdf_converter_spec.rb[1:3:1:5] | passed | 0.00068 seconds | +./spec/app/pdf_converter_spec.rb[1:3:2:1] | passed | 0.0005 seconds | +./spec/app/pdf_converter_spec.rb[1:3:2:2] | passed | 0.00071 seconds | +./spec/app/pdf_converter_spec.rb[1:3:3:1] | passed | 0.00063 seconds | +./spec/app/pdf_converter_spec.rb[1:3:3:2] | passed | 0.00064 seconds | ./spec/app/pdf_converter_spec.rb[1:3:4:1] | passed | 0.00051 seconds | -./spec/app/pdf_converter_spec.rb[1:3:5:1] | passed | 0.0006 seconds | -./spec/app/pdf_converter_spec.rb[1:3:6:1] | passed | 0.00034 seconds | -./spec/app/pdf_converter_spec.rb[1:3:7:1] | passed | 0.00045 seconds | -./spec/app/pdf_converter_spec.rb[1:3:8:1] | passed | 0.00046 seconds | -./spec/app/pdf_converter_spec.rb[1:3:9:1] | passed | 0.00062 seconds | -./spec/app/pdf_converter_spec.rb[1:3:9:2] | passed | 0.00069 seconds | -./spec/app/pdf_converter_spec.rb[1:4:1:1] | passed | 0.00052 seconds | -./spec/app/pdf_converter_spec.rb[1:4:1:2] | passed | 0.00051 seconds | -./spec/app/pdf_converter_spec.rb[1:4:2:1] | passed | 0.00037 seconds | -./spec/app/pdf_converter_spec.rb[1:4:3:1] | passed | 0.0005 seconds | -./spec/app/pdf_converter_spec.rb[1:4:3:2] | passed | 0.00047 seconds | -./spec/app/pdf_converter_spec.rb[1:4:4:1] | passed | 0.00055 seconds | -./spec/app/pdf_converter_spec.rb[1:4:4:2] | passed | 0.00053 seconds | -./spec/app/pdf_converter_spec.rb[1:5:1:1] | passed | 0.00017 seconds | -./spec/app/pdf_converter_spec.rb[1:5:2:1] | passed | 0.0002 seconds | -./spec/app/pdf_converter_spec.rb[1:5:2:2] | passed | 0.0002 seconds | -./spec/app/pdf_converter_spec.rb[1:5:3:1] | passed | 0.00028 seconds | -./spec/app/pdf_converter_spec.rb[1:5:3:2] | passed | 0.00019 seconds | -./spec/app/pdf_converter_spec.rb[1:5:3:3] | passed | 0.00016 seconds | -./spec/app/pdf_converter_spec.rb[1:6:1:1] | passed | 0.00046 seconds | -./spec/app/pdf_converter_spec.rb[1:6:1:2] | passed | 0.00053 seconds | -./spec/app/pdf_converter_spec.rb[1:6:2:1] | passed | 0.00451 seconds | -./spec/app/pdf_converter_spec.rb[1:6:3:1] | passed | 0.00116 seconds | -./spec/app/pdf_converter_spec.rb[1:7:1:1] | passed | 0.00019 seconds | -./spec/app/pdf_converter_spec.rb[1:7:1:2] | passed | 0.00028 seconds | -./spec/app/pdf_converter_spec.rb[1:7:2:1] | passed | 0.00018 seconds | -./spec/app/pdf_converter_spec.rb[1:7:2:2] | passed | 0.00016 seconds | -./spec/app/pdf_converter_spec.rb[1:7:2:3] | passed | 0.00016 seconds | -./spec/app/pdf_converter_spec.rb[1:8:1] | passed | 0.00037 seconds | -./spec/app/pdf_converter_spec.rb[1:8:2] | passed | 0.00062 seconds | -./spec/app/pdf_converter_spec.rb[1:8:3] | passed | 0.00036 seconds | -./spec/app/pdf_converter_spec.rb[1:9:1:1] | passed | 0.00051 seconds | -./spec/app/pdf_converter_spec.rb[1:9:2:1] | passed | 0.00056 seconds | -./spec/app/pdf_converter_spec.rb[1:10:1:1] | passed | 0.00043 seconds | -./spec/app/pdf_converter_spec.rb[1:10:1:2] | passed | 0.00045 seconds | -./spec/app/pdf_converter_spec.rb[1:10:1:3] | passed | 0.0006 seconds | -./spec/app/pdf_converter_spec.rb[1:10:1:4] | passed | 0.00046 seconds | -./spec/app/pdf_converter_spec.rb[1:10:2:1] | passed | 0.00053 seconds | -./spec/app/pdf_converter_spec.rb[1:10:2:2] | passed | 0.00044 seconds | -./spec/app/pdf_converter_spec.rb[1:11:1:1] | passed | 0.00118 seconds | -./spec/app/pdf_converter_spec.rb[1:11:1:2] | passed | 0.00033 seconds | -./spec/app/pdf_converter_spec.rb[1:11:2:1] | passed | 0.00018 seconds | -./spec/app/pdf_converter_spec.rb[1:12:1] | passed | 0.00018 seconds | -./spec/app/pdf_converter_spec.rb[1:12:2] | passed | 0.0002 seconds | -./spec/app/pdf_converter_spec.rb[1:12:3] | passed | 0.00018 seconds | -./spec/app/pdf_converter_spec.rb[1:12:4] | passed | 0.00015 seconds | -./spec/app/pdf_converter_spec.rb[1:12:5] | passed | 0.00018 seconds | -./spec/app/pdf_converter_spec.rb[1:13:1] | passed | 0.00019 seconds | -./spec/app/pdf_downloader_spec.rb[1:1:1] | passed | 0.00003 seconds | -./spec/app/pdf_downloader_spec.rb[1:2:1] | passed | 0.00002 seconds | -./spec/app/pdf_downloader_spec.rb[1:3:1] | passed | 0.00006 seconds | -./spec/app/pdf_downloader_spec.rb[1:3:2] | passed | 0.00007 seconds | -./spec/app/pdf_downloader_spec.rb[1:3:3] | passed | 0.00008 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:1:1] | passed | 0.00094 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:1:2] | passed | 0.00051 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:1:3] | passed | 0.00089 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:1:4] | passed | 0.00026 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:2:1] | passed | 0.00075 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:3:1] | passed | 0.00125 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:4:1] | passed | 0.00021 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:5:1] | passed | 0.00005 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:6:1] | passed | 0.00018 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:7:1] | passed | 0.00007 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:8:1] | passed | 0.00026 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:8:2] | passed | 0.00052 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:9:1] | passed | 0.00033 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:10:1] | passed | 0.0006 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:10:2] | passed | 0.0006 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:11:1] | passed | 0.00304 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:12:1] | passed | 0.00033 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:13:1] | passed | 0.00043 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:14:1] | passed | 3.01 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:15:1] | passed | 3.01 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:16:1] | passed | 3.01 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:17:1] | passed | 0.00055 seconds | -./spec/app/pdf_downloader_spec.rb[1:4:18:1] | passed | 0.00039 seconds | -./spec/app/pdf_downloader_spec.rb[1:5:1:1] | passed | 0.00008 seconds | +./spec/app/pdf_converter_spec.rb[1:3:5:1] | passed | 0.00047 seconds | +./spec/app/pdf_converter_spec.rb[1:3:6:1] | passed | 0.00033 seconds | +./spec/app/pdf_converter_spec.rb[1:3:7:1] | passed | 0.00032 seconds | +./spec/app/pdf_converter_spec.rb[1:3:8:1] | passed | 0.00038 seconds | +./spec/app/pdf_converter_spec.rb[1:3:9:1] | passed | 0.00061 seconds | +./spec/app/pdf_converter_spec.rb[1:3:9:2] | passed | 0.0006 seconds | +./spec/app/pdf_converter_spec.rb[1:4:1:1] | passed | 0.00042 seconds | +./spec/app/pdf_converter_spec.rb[1:4:1:2] | passed | 0.00067 seconds | +./spec/app/pdf_converter_spec.rb[1:4:2:1] | passed | 0.00304 seconds | +./spec/app/pdf_converter_spec.rb[1:4:3:1] | passed | 0.00042 seconds | +./spec/app/pdf_converter_spec.rb[1:4:3:2] | passed | 0.00044 seconds | +./spec/app/pdf_converter_spec.rb[1:4:4:1] | passed | 0.00049 seconds | +./spec/app/pdf_converter_spec.rb[1:4:4:2] | passed | 0.00052 seconds | +./spec/app/pdf_converter_spec.rb[1:5:1:1] | passed | 0.00018 seconds | +./spec/app/pdf_converter_spec.rb[1:5:2:1] | passed | 0.00016 seconds | +./spec/app/pdf_converter_spec.rb[1:5:2:2] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:5:3:1] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:5:3:2] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:5:3:3] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:6:1:1] | passed | 0.00038 seconds | +./spec/app/pdf_converter_spec.rb[1:6:1:2] | passed | 0.00038 seconds | +./spec/app/pdf_converter_spec.rb[1:6:2:1] | passed | 0.00058 seconds | +./spec/app/pdf_converter_spec.rb[1:6:3:1] | passed | 0.00096 seconds | +./spec/app/pdf_converter_spec.rb[1:7:1:1] | passed | 0.00012 seconds | +./spec/app/pdf_converter_spec.rb[1:7:1:2] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:7:2:1] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:7:2:2] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:7:2:3] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:8:1] | passed | 0.00025 seconds | +./spec/app/pdf_converter_spec.rb[1:8:2] | passed | 0.00025 seconds | +./spec/app/pdf_converter_spec.rb[1:8:3] | passed | 0.00046 seconds | +./spec/app/pdf_converter_spec.rb[1:9:1:1] | passed | 0.00037 seconds | +./spec/app/pdf_converter_spec.rb[1:9:2:1] | passed | 0.00045 seconds | +./spec/app/pdf_converter_spec.rb[1:10:1:1] | passed | 0.00039 seconds | +./spec/app/pdf_converter_spec.rb[1:10:1:2] | passed | 0.00037 seconds | +./spec/app/pdf_converter_spec.rb[1:10:1:3] | passed | 0.00037 seconds | +./spec/app/pdf_converter_spec.rb[1:10:1:4] | passed | 0.00038 seconds | +./spec/app/pdf_converter_spec.rb[1:10:2:1] | passed | 0.00046 seconds | +./spec/app/pdf_converter_spec.rb[1:10:2:2] | passed | 0.00042 seconds | +./spec/app/pdf_converter_spec.rb[1:11:1:1] | passed | 0.00024 seconds | +./spec/app/pdf_converter_spec.rb[1:11:1:2] | passed | 0.00021 seconds | +./spec/app/pdf_converter_spec.rb[1:11:2:1] | passed | 0.00015 seconds | +./spec/app/pdf_converter_spec.rb[1:12:1] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:12:2] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:12:3] | passed | 0.00013 seconds | +./spec/app/pdf_converter_spec.rb[1:12:4] | passed | 0.00014 seconds | +./spec/app/pdf_converter_spec.rb[1:12:5] | passed | 0.00012 seconds | +./spec/app/pdf_converter_spec.rb[1:13:1] | passed | 0.00014 seconds | +./spec/app/pdf_downloader_spec.rb[1:1:1] | failed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:2:1] | failed | 0.00004 seconds | +./spec/app/pdf_downloader_spec.rb[1:3:1] | passed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:3:2] | passed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:3:3] | passed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:1:1] | failed | 0.00009 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:1:2] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:1:3] | failed | 0.00006 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:1:4] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:2:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:3:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:4:1] | failed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:5:1] | failed | 0.00002 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:6:1] | failed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:7:1] | failed | 0.00002 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:8:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:8:2] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:9:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:10:1] | failed | 0.00013 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:10:2] | failed | 0.00008 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:11:1] | failed | 0.00076 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:12:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:13:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:14:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:15:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:16:1] | failed | 0.00005 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:17:1] | failed | 0.00011 seconds | +./spec/app/pdf_downloader_spec.rb[1:4:18:1] | failed | 0.00044 seconds | +./spec/app/pdf_downloader_spec.rb[1:5:1:1] | passed | 0.00003 seconds | ./spec/app/pdf_downloader_spec.rb[1:5:2:1] | passed | 0.00003 seconds | -./spec/app/pdf_downloader_spec.rb[1:5:3:1] | passed | 0.00009 seconds | -./spec/app/pdf_downloader_spec.rb[1:5:4:1] | passed | 0.00007 seconds | -./spec/app/pdf_downloader_spec.rb[1:5:5:1] | passed | 0.00003 seconds | -./spec/app/pdf_downloader_spec.rb[1:5:6:1] | passed | 0.00008 seconds | -./spec/app/pdf_downloader_spec.rb[1:5:7:1] | passed | 0.00003 seconds | -./spec/app/pdf_downloader_spec.rb[1:6:1:1] | passed | 0.00004 seconds | -./spec/app/pdf_downloader_spec.rb[1:6:2:1] | passed | 0.00005 seconds | -./spec/app/pdf_downloader_spec.rb[1:6:3:1] | passed | 0.00004 seconds | -./spec/app/pdf_downloader_spec.rb[1:6:4:1] | passed | 0.00004 seconds | -./spec/app/pdf_downloader_spec.rb[1:6:5:1] | passed | 0.00012 seconds | -./spec/app/pdf_downloader_spec.rb[1:6:6:1] | passed | 0.00005 seconds | -./spec/app/pdf_downloader_spec.rb[1:7:1:1] | passed | 0.00076 seconds | -./spec/app/pdf_downloader_spec.rb[1:7:2:1] | passed | 3.01 seconds | -./spec/app/pdf_downloader_spec.rb[1:8:1:1] | passed | 0.00056 seconds | -./spec/app/pdf_downloader_spec.rb[1:8:2:1] | passed | 0.0008 seconds | -./spec/app/pdf_downloader_spec.rb[1:8:3:1] | passed | 0.00017 seconds | -./spec/app/pdf_downloader_spec.rb[1:8:4:1] | passed | 0.00055 seconds | -./spec/app/pdf_downloader_spec.rb[1:8:5:1] | passed | 0.00054 seconds | -./spec/app/pdf_downloader_spec.rb[1:8:6:1] | passed | 0.00055 seconds | -./spec/app/pdf_downloader_spec.rb[1:9:1:1] | passed | 0.00095 seconds | -./spec/app/pdf_downloader_spec.rb[1:9:1:2] | passed | 0.0003 seconds | -./spec/app/pdf_downloader_spec.rb[1:9:2:1] | passed | 0.0005 seconds | -./spec/app/pdf_downloader_spec.rb[1:10:1] | passed | 0.00005 seconds | -./spec/app/pdf_downloader_spec.rb[1:10:2] | passed | 0.00005 seconds | -./spec/app/pdf_downloader_spec.rb[1:11:1] | passed | 0.00007 seconds | -./spec/app/pdf_downloader_spec.rb[1:12:1] | passed | 0.00004 seconds | -./spec/app/request_validator_spec.rb[1:1:1] | passed | 0.00006 seconds | -./spec/app/request_validator_spec.rb[1:1:2] | passed | 0.00006 seconds | -./spec/app/request_validator_spec.rb[1:1:3] | passed | 0.00007 seconds | -./spec/app/request_validator_spec.rb[1:1:4] | passed | 0.0001 seconds | -./spec/app/request_validator_spec.rb[1:1:5] | passed | 0.00012 seconds | -./spec/app/request_validator_spec.rb[1:2:1] | passed | 0.00006 seconds | +./spec/app/pdf_downloader_spec.rb[1:5:3:1] | passed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:5:4:1] | passed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:5:5:1] | passed | 0.00002 seconds | +./spec/app/pdf_downloader_spec.rb[1:5:6:1] | passed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:5:7:1] | passed | 0.00002 seconds | +./spec/app/pdf_downloader_spec.rb[1:6:1:1] | failed | 0.00015 seconds | +./spec/app/pdf_downloader_spec.rb[1:6:2:1] | failed | 0.00017 seconds | +./spec/app/pdf_downloader_spec.rb[1:6:3:1] | failed | 0.00014 seconds | +./spec/app/pdf_downloader_spec.rb[1:6:4:1] | failed | 0.00017 seconds | +./spec/app/pdf_downloader_spec.rb[1:6:5:1] | failed | 0.00014 seconds | +./spec/app/pdf_downloader_spec.rb[1:6:6:1] | failed | 0.00015 seconds | +./spec/app/pdf_downloader_spec.rb[1:7:1:1] | failed | 0.00031 seconds | +./spec/app/pdf_downloader_spec.rb[1:7:2:1] | failed | 0.00022 seconds | +./spec/app/pdf_downloader_spec.rb[1:8:1:1] | failed | 0.00006 seconds | +./spec/app/pdf_downloader_spec.rb[1:8:2:1] | failed | 0.00014 seconds | +./spec/app/pdf_downloader_spec.rb[1:8:3:1] | failed | 0.00021 seconds | +./spec/app/pdf_downloader_spec.rb[1:8:4:1] | failed | 0.00018 seconds | +./spec/app/pdf_downloader_spec.rb[1:8:5:1] | failed | 0.0002 seconds | +./spec/app/pdf_downloader_spec.rb[1:8:6:1] | failed | 0.00019 seconds | +./spec/app/pdf_downloader_spec.rb[1:9:1:1] | failed | 0.00007 seconds | +./spec/app/pdf_downloader_spec.rb[1:9:1:2] | failed | 0.00006 seconds | +./spec/app/pdf_downloader_spec.rb[1:9:2:1] | failed | 0.00012 seconds | +./spec/app/pdf_downloader_spec.rb[1:10:1] | passed | 0.00004 seconds | +./spec/app/pdf_downloader_spec.rb[1:10:2] | passed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:11:1] | failed | 0.00003 seconds | +./spec/app/pdf_downloader_spec.rb[1:12:1] | failed | 0.00003 seconds | +./spec/app/request_validator_spec.rb[1:1:1] | failed | 0.00006 seconds | +./spec/app/request_validator_spec.rb[1:1:2] | failed | 0.00007 seconds | +./spec/app/request_validator_spec.rb[1:1:3] | failed | 0.00006 seconds | +./spec/app/request_validator_spec.rb[1:1:4] | failed | 0.00007 seconds | +./spec/app/request_validator_spec.rb[1:1:5] | failed | 0.00006 seconds | +./spec/app/request_validator_spec.rb[1:2:1] | passed | 0.00007 seconds | ./spec/app/request_validator_spec.rb[1:2:2] | passed | 0.00006 seconds | -./spec/app/request_validator_spec.rb[1:2:3] | passed | 0.00006 seconds | -./spec/app/request_validator_spec.rb[1:2:4] | passed | 0.00007 seconds | -./spec/app/request_validator_spec.rb[1:2:5] | passed | 0.00012 seconds | -./spec/app/request_validator_spec.rb[1:2:6] | passed | 0.00013 seconds | -./spec/app/request_validator_spec.rb[1:3:1:1] | passed | 0.00009 seconds | -./spec/app/request_validator_spec.rb[1:3:2:1] | passed | 0.00014 seconds | -./spec/app/request_validator_spec.rb[1:3:3:1] | passed | 0.00008 seconds | -./spec/app/request_validator_spec.rb[1:3:4:1] | passed | 0.00008 seconds | -./spec/app/request_validator_spec.rb[1:4:1:1] | passed | 0.0002 seconds | -./spec/app/request_validator_spec.rb[1:4:2:1] | passed | 0.00019 seconds | -./spec/app/request_validator_spec.rb[1:4:3:1] | passed | 0.00009 seconds | -./spec/app/request_validator_spec.rb[1:4:4:1] | passed | 0.00382 seconds | -./spec/app/request_validator_spec.rb[1:5:1:1] | passed | 0.00019 seconds | -./spec/app/request_validator_spec.rb[1:5:1:2] | passed | 0.00049 seconds | -./spec/app/request_validator_spec.rb[1:5:1:3] | passed | 0.00039 seconds | -./spec/app/request_validator_spec.rb[1:5:1:4] | passed | 0.00021 seconds | -./spec/app/request_validator_spec.rb[1:5:2:1] | passed | 0.00016 seconds | -./spec/app/request_validator_spec.rb[1:5:3:1] | passed | 0.00021 seconds | -./spec/app/request_validator_spec.rb[1:5:4:1] | passed | 0.0004 seconds | -./spec/app/request_validator_spec.rb[1:5:5:1] | passed | 0.0004 seconds | -./spec/app/request_validator_spec.rb[1:5:6:1] | passed | 0.0003 seconds | -./spec/app/request_validator_spec.rb[1:5:7:1] | passed | 0.00036 seconds | -./spec/app/request_validator_spec.rb[1:5:8:1] | passed | 0.00046 seconds | -./spec/app/request_validator_spec.rb[1:5:9:1] | passed | 0.00039 seconds | -./spec/app/request_validator_spec.rb[1:5:10:1] | passed | 0.00021 seconds | -./spec/app/request_validator_spec.rb[1:5:11:1] | passed | 0.00052 seconds | -./spec/app/request_validator_spec.rb[1:5:12:1] | passed | 0.00049 seconds | -./spec/app/request_validator_spec.rb[1:5:13:1] | passed | 0.00051 seconds | -./spec/app/request_validator_spec.rb[1:5:14:1] | passed | 0.0004 seconds | -./spec/app/request_validator_spec.rb[1:5:15:1] | passed | 0.00051 seconds | -./spec/app/request_validator_spec.rb[1:5:16:1] | passed | 0.00024 seconds | -./spec/app/request_validator_spec.rb[1:5:16:2] | passed | 0.00018 seconds | -./spec/app/request_validator_spec.rb[1:6:1] | passed | 0.00021 seconds | -./spec/app/response_builder_spec.rb[1:1:1] | passed | 0.00003 seconds | +./spec/app/request_validator_spec.rb[1:2:3] | passed | 0.00007 seconds | +./spec/app/request_validator_spec.rb[1:2:4] | passed | 0.00033 seconds | +./spec/app/request_validator_spec.rb[1:2:5] | passed | 0.00008 seconds | +./spec/app/request_validator_spec.rb[1:2:6] | passed | 0.00007 seconds | +./spec/app/request_validator_spec.rb[1:3:1:1] | passed | 0.0001 seconds | +./spec/app/request_validator_spec.rb[1:3:2:1] | passed | 0.00009 seconds | +./spec/app/request_validator_spec.rb[1:3:3:1] | passed | 0.00012 seconds | +./spec/app/request_validator_spec.rb[1:3:4:1] | passed | 0.00014 seconds | +./spec/app/request_validator_spec.rb[1:4:1:1] | passed | 0.00008 seconds | +./spec/app/request_validator_spec.rb[1:4:2:1] | passed | 0.00009 seconds | +./spec/app/request_validator_spec.rb[1:4:3:1] | passed | 0.00018 seconds | +./spec/app/request_validator_spec.rb[1:4:4:1] | passed | 0.00014 seconds | +./spec/app/request_validator_spec.rb[1:5:1:1] | failed | 0.0002 seconds | +./spec/app/request_validator_spec.rb[1:5:1:2] | failed | 0.00059 seconds | +./spec/app/request_validator_spec.rb[1:5:1:3] | failed | 0.00024 seconds | +./spec/app/request_validator_spec.rb[1:5:1:4] | failed | 0.00024 seconds | +./spec/app/request_validator_spec.rb[1:5:2:1] | failed | 0.00018 seconds | +./spec/app/request_validator_spec.rb[1:5:3:1] | passed | 0.00015 seconds | +./spec/app/request_validator_spec.rb[1:5:4:1] | passed | 0.00017 seconds | +./spec/app/request_validator_spec.rb[1:5:5:1] | passed | 0.0002 seconds | +./spec/app/request_validator_spec.rb[1:5:6:1] | passed | 0.00017 seconds | +./spec/app/request_validator_spec.rb[1:5:7:1] | passed | 0.00017 seconds | +./spec/app/request_validator_spec.rb[1:5:8:1] | passed | 0.00015 seconds | +./spec/app/request_validator_spec.rb[1:5:9:1] | failed | 0.00024 seconds | +./spec/app/request_validator_spec.rb[1:5:10:1] | failed | 0.00018 seconds | +./spec/app/request_validator_spec.rb[1:5:11:1] | failed | 0.0002 seconds | +./spec/app/request_validator_spec.rb[1:5:12:1] | failed | 0.00018 seconds | +./spec/app/request_validator_spec.rb[1:5:13:1] | failed | 0.00019 seconds | +./spec/app/request_validator_spec.rb[1:5:14:1] | passed | 0.00022 seconds | +./spec/app/request_validator_spec.rb[1:5:15:1] | passed | 0.0002 seconds | +./spec/app/request_validator_spec.rb[1:5:16:1] | failed | 0.00026 seconds | +./spec/app/request_validator_spec.rb[1:5:16:2] | passed | 0.00024 seconds | +./spec/app/request_validator_spec.rb[1:6:1] | passed | 0.0001 seconds | +./spec/app/response_builder_spec.rb[1:1:1] | passed | 0.00002 seconds | ./spec/app/response_builder_spec.rb[1:1:2] | passed | 0.00002 seconds | ./spec/app/response_builder_spec.rb[1:1:3] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:2:1:1] | passed | 0.00008 seconds | -./spec/app/response_builder_spec.rb[1:2:1:2] | passed | 0.00004 seconds | +./spec/app/response_builder_spec.rb[1:2:1:1] | passed | 0.00003 seconds | +./spec/app/response_builder_spec.rb[1:2:1:2] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:2:1:3] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:2:1:4] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:2:2:1] | passed | 0.00003 seconds | +./spec/app/response_builder_spec.rb[1:2:2:1] | passed | 0.00002 seconds | ./spec/app/response_builder_spec.rb[1:2:2:2] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:2:3:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:2:3:2] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:2:4:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:2:5:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:3:1:1] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:3:1:2] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:3:1:3] | passed | 0.00004 seconds | +./spec/app/response_builder_spec.rb[1:3:1:2] | passed | 0.00002 seconds | +./spec/app/response_builder_spec.rb[1:3:1:3] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:3:1:4] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:3:2:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:3:2:2] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:3:3:1] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:3:4:1] | passed | 0.00011 seconds | +./spec/app/response_builder_spec.rb[1:3:4:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:3:5:1] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:3:6:1] | passed | 0.00003 seconds | +./spec/app/response_builder_spec.rb[1:3:6:1] | passed | 0.00004 seconds | ./spec/app/response_builder_spec.rb[1:4:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:4:2] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:4:3] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:4:4] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:4:5] | passed | 0.00002 seconds | +./spec/app/response_builder_spec.rb[1:4:3] | passed | 0.00004 seconds | +./spec/app/response_builder_spec.rb[1:4:4] | passed | 0.00002 seconds | +./spec/app/response_builder_spec.rb[1:4:5] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:4:6] | passed | 0.00003 seconds | -./spec/app/response_builder_spec.rb[1:4:7] | passed | 0.00002 seconds | -./spec/app/response_builder_spec.rb[1:4:8] | passed | 0.00009 seconds | +./spec/app/response_builder_spec.rb[1:4:7] | passed | 0.00003 seconds | +./spec/app/response_builder_spec.rb[1:4:8] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:4:9] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:4:10:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:4:11:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:4:12:1] | passed | 0.00003 seconds | ./spec/app/response_builder_spec.rb[1:5:1] | passed | 0.00008 seconds | -./spec/app/response_builder_spec.rb[1:5:2] | passed | 0.00008 seconds | +./spec/app/response_builder_spec.rb[1:5:2] | passed | 0.00004 seconds | ./spec/app/url_validator_spec.rb[1:1:1] | passed | 0.00004 seconds | ./spec/app/url_validator_spec.rb[1:1:2] | passed | 0.00004 seconds | -./spec/app/url_validator_spec.rb[1:2:1] | passed | 0.00006 seconds | -./spec/app/url_validator_spec.rb[1:2:2] | passed | 0.00007 seconds | +./spec/app/url_validator_spec.rb[1:2:1] | passed | 0.00004 seconds | +./spec/app/url_validator_spec.rb[1:2:2] | passed | 0.00004 seconds | ./spec/app/url_validator_spec.rb[1:3:1:1] | passed | 0.00012 seconds | -./spec/app/url_validator_spec.rb[1:3:2:1] | passed | 0.00012 seconds | +./spec/app/url_validator_spec.rb[1:3:2:1] | passed | 0.00013 seconds | ./spec/app/url_validator_spec.rb[1:3:3:1] | passed | 0.00004 seconds | -./spec/app/url_validator_spec.rb[1:3:4:1] | passed | 0.00009 seconds | +./spec/app/url_validator_spec.rb[1:3:4:1] | passed | 0.00004 seconds | ./spec/app/url_validator_spec.rb[1:3:5:1] | passed | 0.00013 seconds | -./spec/app/url_validator_spec.rb[1:4:1:1] | passed | 0.0004 seconds | -./spec/app/url_validator_spec.rb[1:4:2:1] | passed | 0.00014 seconds | -./spec/app/url_validator_spec.rb[1:4:3:1] | passed | 0.00045 seconds | -./spec/app/url_validator_spec.rb[1:4:4:1] | passed | 0.0001 seconds | +./spec/app/url_validator_spec.rb[1:4:1:1] | passed | 0.00012 seconds | +./spec/app/url_validator_spec.rb[1:4:2:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:4:3:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:4:4:1] | passed | 0.00004 seconds | ./spec/app/url_validator_spec.rb[1:4:5:1] | passed | 0.00004 seconds | -./spec/app/url_validator_spec.rb[1:4:6:1] | passed | 0.00007 seconds | -./spec/app/url_validator_spec.rb[1:5:1:1] | passed | 0.00005 seconds | -./spec/app/url_validator_spec.rb[1:5:2:1] | passed | 0.00006 seconds | -./spec/app/url_validator_spec.rb[1:5:3:1] | passed | 0.0001 seconds | -./spec/app/url_validator_spec.rb[1:5:4:1] | passed | 0.00007 seconds | -./spec/app/url_validator_spec.rb[1:5:5:1] | passed | 0.00007 seconds | -./spec/app/url_validator_spec.rb[1:5:6:1] | passed | 0.00005 seconds | -./spec/app/url_validator_spec.rb[1:5:7:1] | passed | 0.00006 seconds | -./spec/app/url_validator_spec.rb[1:6:1:1] | passed | 0.00024 seconds | -./spec/app/url_validator_spec.rb[1:6:1:2] | passed | 0.00025 seconds | -./spec/app/url_validator_spec.rb[1:6:2:1] | passed | 0.00018 seconds | -./spec/app/url_validator_spec.rb[1:6:2:2] | passed | 0.00019 seconds | -./spec/app/url_validator_spec.rb[1:7:1:1:1] | passed | 0.00012 seconds | -./spec/app/url_validator_spec.rb[1:7:1:2:1] | passed | 0.0002 seconds | -./spec/app/url_validator_spec.rb[1:7:1:3:1] | passed | 0.00012 seconds | -./spec/app/url_validator_spec.rb[1:7:2:1:1] | passed | 0.0002 seconds | -./spec/app/url_validator_spec.rb[1:7:2:2:1] | passed | 0.00017 seconds | -./spec/app/url_validator_spec.rb[1:7:2:3:1] | passed | 0.00015 seconds | -./spec/app/url_validator_spec.rb[1:7:3:1:1] | passed | 0.00012 seconds | +./spec/app/url_validator_spec.rb[1:4:6:1] | passed | 0.00005 seconds | +./spec/app/url_validator_spec.rb[1:5:1:1] | passed | 0.00004 seconds | +./spec/app/url_validator_spec.rb[1:5:2:1] | passed | 0.00004 seconds | +./spec/app/url_validator_spec.rb[1:5:3:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:5:4:1] | passed | 0.00004 seconds | +./spec/app/url_validator_spec.rb[1:5:5:1] | passed | 0.00004 seconds | +./spec/app/url_validator_spec.rb[1:5:6:1] | passed | 0.00004 seconds | +./spec/app/url_validator_spec.rb[1:5:7:1] | passed | 0.00004 seconds | +./spec/app/url_validator_spec.rb[1:6:1:1] | passed | 0.00016 seconds | +./spec/app/url_validator_spec.rb[1:6:1:2] | passed | 0.00018 seconds | +./spec/app/url_validator_spec.rb[1:6:2:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:6:2:2] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:7:1:1:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:7:1:2:1] | passed | 0.00014 seconds | +./spec/app/url_validator_spec.rb[1:7:1:3:1] | passed | 0.00014 seconds | +./spec/app/url_validator_spec.rb[1:7:2:1:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:7:2:2:1] | passed | 0.00014 seconds | +./spec/app/url_validator_spec.rb[1:7:2:3:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:7:3:1:1] | passed | 0.00013 seconds | ./spec/app/url_validator_spec.rb[1:7:3:2:1] | passed | 0.00013 seconds | -./spec/app/url_validator_spec.rb[1:7:4:1:1] | passed | 0.00012 seconds | -./spec/app/url_validator_spec.rb[1:7:4:2:1] | passed | 0.00014 seconds | -./spec/app/url_validator_spec.rb[1:7:4:3:1] | passed | 0.00014 seconds | -./spec/app/url_validator_spec.rb[1:7:4:4:1] | passed | 0.00018 seconds | -./spec/app/url_validator_spec.rb[1:8:1:1] | passed | 0.00029 seconds | -./spec/app/url_validator_spec.rb[1:8:2:1] | passed | 0.00018 seconds | -./spec/app/url_validator_spec.rb[1:9:1:1] | passed | 0.00014 seconds | -./spec/app/url_validator_spec.rb[1:9:2:1] | passed | 0.00017 seconds | -./spec/app/url_validator_spec.rb[1:10:1:1] | passed | 0.00022 seconds | -./spec/app/url_validator_spec.rb[1:10:2:1] | passed | 0.00024 seconds | -./spec/app/url_validator_spec.rb[1:10:3:1] | passed | 0.00021 seconds | -./spec/app/url_validator_spec.rb[1:11:1:1] | passed | 0.00022 seconds | -./spec/app/url_validator_spec.rb[1:11:2:1] | passed | 0.00015 seconds | -./spec/app/url_validator_spec.rb[1:12:1:1] | passed | 0.00008 seconds | -./spec/app/webhook_notifier_spec.rb[1:1:1] | passed | 0.00003 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:1:1] | passed | 0.00069 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:1:2] | passed | 0.00061 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:1:3] | passed | 0.0003 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:1:4] | passed | 0.00108 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:1:5] | passed | 0.00033 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:1:6] | passed | 0.0007 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:2:1:1] | passed | 0.00049 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:2:2:1] | passed | 0.00071 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:2:3:1] | passed | 0.00063 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:2:4:1] | passed | 0.00049 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:3:1:1] | passed | 0.00144 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:3:1:2] | passed | 0.00074 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:3:2:1] | passed | 0.00094 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:3:3:1] | passed | 0.00055 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:4:1:1] | passed | 0.00055 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:5:1:1] | passed | 0.00027 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:5:2:1] | passed | 0.00046 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:5:3:1] | passed | 0.00025 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:5:4:1] | passed | 0.00024 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:6:1] | passed | 0.00005 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:7:1] | passed | 0.00059 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:7:2] | passed | 0.00055 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:8:1] | passed | 0.00036 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:8:2] | passed | 0.0007 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:9:1] | passed | 0.00105 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:10:1] | passed | 0.00093 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:11:1] | passed | 0.00066 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:12:1] | passed | 0.00054 seconds | -./spec/app/webhook_notifier_spec.rb[1:2:13:1] | passed | 0.00143 seconds | -./spec/app/webhook_notifier_spec.rb[1:3:1:1] | passed | 0.00004 seconds | -./spec/app/webhook_notifier_spec.rb[1:3:2:1] | passed | 0.00004 seconds | -./spec/app_spec.rb[1:1:1:1] | passed | 0.0007 seconds | -./spec/app_spec.rb[1:1:1:2] | passed | 0.00576 seconds | -./spec/app_spec.rb[1:1:1:3] | passed | 0.00074 seconds | -./spec/app_spec.rb[1:1:1:4] | passed | 0.0007 seconds | -./spec/app_spec.rb[1:1:1:5] | passed | 0.0011 seconds | -./spec/app_spec.rb[1:1:2:1] | passed | 0.00045 seconds | -./spec/app_spec.rb[1:1:2:2] | passed | 0.00044 seconds | -./spec/app_spec.rb[1:1:2:3] | passed | 0.00047 seconds | -./spec/app_spec.rb[1:1:3:1] | passed | 0.00087 seconds | -./spec/app_spec.rb[1:1:3:2] | passed | 0.00043 seconds | -./spec/app_spec.rb[1:1:3:3] | passed | 0.00042 seconds | -./spec/app_spec.rb[1:1:4:1] | passed | 0.00072 seconds | -./spec/app_spec.rb[1:1:4:2] | passed | 0.00048 seconds | -./spec/app_spec.rb[1:2:1:1] | passed | 0.00057 seconds | -./spec/app_spec.rb[1:2:1:2] | passed | 0.00061 seconds | -./spec/app_spec.rb[1:2:1:3] | passed | 0.00058 seconds | -./spec/app_spec.rb[1:2:1:4] | passed | 0.00061 seconds | -./spec/app_spec.rb[1:2:1:5] | passed | 0.00059 seconds | -./spec/app_spec.rb[1:2:1:6] | passed | 0.00056 seconds | -./spec/app_spec.rb[1:2:1:7] | passed | 0.00057 seconds | -./spec/app_spec.rb[1:2:2:1] | passed | 0.00042 seconds | -./spec/app_spec.rb[1:2:2:2] | passed | 0.00045 seconds | -./spec/app_spec.rb[1:2:2:3] | passed | 0.00044 seconds | -./spec/app_spec.rb[1:2:2:4] | passed | 0.00045 seconds | -./spec/app_spec.rb[1:2:3:1] | passed | 0.00048 seconds | -./spec/app_spec.rb[1:2:3:2] | passed | 0.00049 seconds | -./spec/app_spec.rb[1:2:3:3] | passed | 0.0005 seconds | -./spec/app_spec.rb[1:2:4:1] | passed | 0.00056 seconds | -./spec/app_spec.rb[1:2:4:2] | passed | 0.00056 seconds | -./spec/app_spec.rb[1:2:4:3] | passed | 0.00054 seconds | -./spec/app_spec.rb[1:2:5:1] | passed | 0.00062 seconds | -./spec/app_spec.rb[1:2:6:1] | passed | 0.00064 seconds | -./spec/app_spec.rb[1:3:1:1] | passed | 0.0005 seconds | -./spec/app_spec.rb[1:3:1:2] | passed | 0.0004 seconds | -./spec/app_spec.rb[1:3:1:3] | passed | 0.00041 seconds | -./spec/app_spec.rb[1:3:2:1] | passed | 0.00039 seconds | -./spec/app_spec.rb[1:3:2:2] | passed | 0.0004 seconds | -./spec/app_spec.rb[1:3:3:1] | passed | 0.00042 seconds | -./spec/app_spec.rb[1:3:3:2] | passed | 0.00044 seconds | -./spec/app_spec.rb[1:4:1:1] | passed | 0.00038 seconds | -./spec/app_spec.rb[1:4:1:2] | passed | 0.00039 seconds | -./spec/app_spec.rb[1:4:1:3] | passed | 0.00121 seconds | -./spec/app_spec.rb[1:4:2:1] | passed | 0.00032 seconds | -./spec/app_spec.rb[1:4:2:2] | passed | 0.00034 seconds | -./spec/app_spec.rb[1:5:1:1] | passed | 0.00037 seconds | -./spec/app_spec.rb[1:5:1:2] | passed | 0.00037 seconds | -./spec/app_spec.rb[1:5:1:3] | passed | 0.00037 seconds | -./spec/app_spec.rb[1:5:2:1] | passed | 0.00089 seconds | -./spec/app_spec.rb[1:5:2:2] | passed | 0.00041 seconds | -./spec/app_spec.rb[1:5:2:3] | passed | 0.00038 seconds | -./spec/app_spec.rb[1:6:1:1] | passed | 0.00307 seconds | -./spec/app_spec.rb[1:6:1:2] | passed | 0.00046 seconds | -./spec/app_spec.rb[1:6:1:3] | passed | 0.0006 seconds | -./spec/app_spec.rb[1:6:1:4] | passed | 0.00051 seconds | -./spec/app_spec.rb[1:6:2:1] | passed | 0.00043 seconds | -./spec/app_spec.rb[1:6:3:1] | passed | 0.00038 seconds | -./spec/app_spec.rb[1:6:4:1] | passed | 0.0004 seconds | -./spec/app_spec.rb[1:6:4:2] | passed | 0.00108 seconds | -./spec/app_spec.rb[1:6:5:1] | passed | 0.0004 seconds | -./spec/app_spec.rb[1:6:5:2] | passed | 0.0004 seconds | -./spec/integration/localstack_integration_spec.rb[1:1:1] | failed | 0.06558 seconds | -./spec/integration/localstack_integration_spec.rb[1:1:2] | failed | 0.02875 seconds | -./spec/lib/aws_config_spec.rb[1:1:1:1] | passed | 0.00052 seconds | -./spec/lib/aws_config_spec.rb[1:1:1:2] | passed | 0.00877 seconds | -./spec/lib/aws_config_spec.rb[1:1:2:1] | passed | 0.00396 seconds | -./spec/lib/aws_config_spec.rb[1:1:3:1] | passed | 0.00059 seconds | -./spec/lib/aws_config_spec.rb[1:1:4:1] | passed | 0.00009 seconds | -./spec/lib/aws_config_spec.rb[1:1:4:2] | passed | 0.00009 seconds | -./spec/lib/aws_config_spec.rb[1:1:4:3] | passed | 0.00011 seconds | -./spec/lib/aws_config_spec.rb[1:1:4:4] | passed | 0.00096 seconds | -./spec/lib/aws_config_spec.rb[1:1:5:1] | passed | 0.00016 seconds | -./spec/lib/aws_config_spec.rb[1:1:5:2] | passed | 0.02389 seconds | +./spec/app/url_validator_spec.rb[1:7:4:1:1] | passed | 0.00014 seconds | +./spec/app/url_validator_spec.rb[1:7:4:2:1] | passed | 0.00017 seconds | +./spec/app/url_validator_spec.rb[1:7:4:3:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:7:4:4:1] | passed | 0.00012 seconds | +./spec/app/url_validator_spec.rb[1:8:1:1] | passed | 0.00015 seconds | +./spec/app/url_validator_spec.rb[1:8:2:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:9:1:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:9:2:1] | passed | 0.00014 seconds | +./spec/app/url_validator_spec.rb[1:10:1:1] | passed | 0.00012 seconds | +./spec/app/url_validator_spec.rb[1:10:2:1] | passed | 0.00016 seconds | +./spec/app/url_validator_spec.rb[1:10:3:1] | passed | 0.00018 seconds | +./spec/app/url_validator_spec.rb[1:11:1:1] | passed | 0.00018 seconds | +./spec/app/url_validator_spec.rb[1:11:2:1] | passed | 0.00013 seconds | +./spec/app/url_validator_spec.rb[1:12:1:1] | passed | 0.00005 seconds | +./spec/app/webhook_notifier_spec.rb[1:1:1] | passed | 0.00041 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:1:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:1:2] | passed | 0.00026 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:1:3] | passed | 0.00026 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:1:4] | passed | 0.0003 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:1:5] | passed | 0.00027 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:1:6] | passed | 0.00072 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:2:1:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:2:2:1] | passed | 0.00059 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:2:3:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:2:4:1] | passed | 0.00024 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:3:1:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:3:1:2] | passed | 0.00024 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:3:2:1] | passed | 0.00023 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:3:3:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:4:1:1] | passed | 0.00023 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:5:1:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:5:2:1] | passed | 0.00024 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:5:3:1] | passed | 0.00024 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:5:4:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:6:1] | passed | 0.00004 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:7:1] | passed | 0.00021 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:7:2] | passed | 0.00037 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:8:1] | passed | 0.00022 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:8:2] | passed | 0.00038 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:9:1] | passed | 0.00028 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:10:1] | passed | 0.00028 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:11:1] | passed | 0.00028 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:12:1] | passed | 0.00555 seconds | +./spec/app/webhook_notifier_spec.rb[1:2:13:1] | passed | 0.00028 seconds | +./spec/app/webhook_notifier_spec.rb[1:3:1:1] | passed | 0.00099 seconds | +./spec/app/webhook_notifier_spec.rb[1:3:2:1] | passed | 0.00005 seconds | +./spec/app_spec.rb[1:1:1:1] | failed | 0.00045 seconds | +./spec/app_spec.rb[1:1:1:2] | failed | 0.00037 seconds | +./spec/app_spec.rb[1:1:1:3] | failed | 0.00068 seconds | +./spec/app_spec.rb[1:1:1:4] | failed | 0.0004 seconds | +./spec/app_spec.rb[1:1:1:5] | failed | 0.00038 seconds | +./spec/app_spec.rb[1:1:2:1] | passed | 0.00044 seconds | +./spec/app_spec.rb[1:1:2:2] | passed | 0.00058 seconds | +./spec/app_spec.rb[1:1:2:3] | failed | 0.0005 seconds | +./spec/app_spec.rb[1:1:3:1] | passed | 0.00048 seconds | +./spec/app_spec.rb[1:1:3:2] | passed | 0.00042 seconds | +./spec/app_spec.rb[1:1:3:3] | failed | 0.00066 seconds | +./spec/app_spec.rb[1:1:4:1] | passed | 0.00069 seconds | +./spec/app_spec.rb[1:1:4:2] | failed | 0.00053 seconds | +./spec/app_spec.rb[1:2:1:1] | failed | 0.00302 seconds | +./spec/app_spec.rb[1:2:1:2] | failed | 0.00097 seconds | +./spec/app_spec.rb[1:2:1:3] | failed | 0.00138 seconds | +./spec/app_spec.rb[1:2:1:4] | failed | 0.00194 seconds | +./spec/app_spec.rb[1:2:1:5] | failed | 0.00132 seconds | +./spec/app_spec.rb[1:2:1:6] | failed | 0.00109 seconds | +./spec/app_spec.rb[1:2:1:7] | failed | 0.00122 seconds | +./spec/app_spec.rb[1:2:2:1] | failed | 0.00034 seconds | +./spec/app_spec.rb[1:2:2:2] | failed | 0.00036 seconds | +./spec/app_spec.rb[1:2:2:3] | failed | 0.00034 seconds | +./spec/app_spec.rb[1:2:2:4] | failed | 0.00037 seconds | +./spec/app_spec.rb[1:2:3:1] | failed | 0.00036 seconds | +./spec/app_spec.rb[1:2:3:2] | failed | 0.00043 seconds | +./spec/app_spec.rb[1:2:3:3] | failed | 0.00035 seconds | +./spec/app_spec.rb[1:2:4:1] | failed | 0.00056 seconds | +./spec/app_spec.rb[1:2:4:2] | failed | 0.00039 seconds | +./spec/app_spec.rb[1:2:4:3] | failed | 0.00036 seconds | +./spec/app_spec.rb[1:2:5:1] | failed | 0.00039 seconds | +./spec/app_spec.rb[1:2:6:1] | failed | 0.00036 seconds | +./spec/app_spec.rb[1:3:1:1] | passed | 0.00045 seconds | +./spec/app_spec.rb[1:3:1:2] | passed | 0.00047 seconds | +./spec/app_spec.rb[1:3:1:3] | passed | 0.00072 seconds | +./spec/app_spec.rb[1:3:2:1] | passed | 0.00174 seconds | +./spec/app_spec.rb[1:3:2:2] | passed | 0.00096 seconds | +./spec/app_spec.rb[1:3:3:1] | passed | 0.00154 seconds | +./spec/app_spec.rb[1:3:3:2] | passed | 0.00141 seconds | +./spec/app_spec.rb[1:4:1:1] | passed | 0.00036 seconds | +./spec/app_spec.rb[1:4:1:2] | passed | 0.0005 seconds | +./spec/app_spec.rb[1:4:1:3] | passed | 0.0004 seconds | +./spec/app_spec.rb[1:4:2:1] | passed | 0.00035 seconds | +./spec/app_spec.rb[1:4:2:2] | passed | 0.00054 seconds | +./spec/app_spec.rb[1:5:1:1] | passed | 0.00041 seconds | +./spec/app_spec.rb[1:5:1:2] | passed | 0.00038 seconds | +./spec/app_spec.rb[1:5:1:3] | passed | 0.00039 seconds | +./spec/app_spec.rb[1:5:2:1] | passed | 0.0004 seconds | +./spec/app_spec.rb[1:5:2:2] | passed | 0.00036 seconds | +./spec/app_spec.rb[1:5:2:3] | passed | 0.00049 seconds | +./spec/app_spec.rb[1:6:1:1] | passed | 0.00038 seconds | +./spec/app_spec.rb[1:6:1:2] | passed | 0.00043 seconds | +./spec/app_spec.rb[1:6:1:3] | passed | 0.00047 seconds | +./spec/app_spec.rb[1:6:1:4] | passed | 0.00059 seconds | +./spec/app_spec.rb[1:6:2:1] | passed | 0.00038 seconds | +./spec/app_spec.rb[1:6:3:1] | passed | 0.00036 seconds | +./spec/app_spec.rb[1:6:4:1] | passed | 0.00037 seconds | +./spec/app_spec.rb[1:6:4:2] | passed | 0.00039 seconds | +./spec/app_spec.rb[1:6:5:1] | passed | 0.00038 seconds | +./spec/app_spec.rb[1:6:5:2] | passed | 0.00036 seconds | +./spec/integration/localstack_integration_spec.rb[1:1:1] | failed | 0.06016 seconds | +./spec/integration/localstack_integration_spec.rb[1:1:2] | failed | 0.02074 seconds | +./spec/lib/aws_config_spec.rb[1:1:1:1] | passed | 0.00041 seconds | +./spec/lib/aws_config_spec.rb[1:1:1:2] | passed | 0.0088 seconds | +./spec/lib/aws_config_spec.rb[1:1:2:1] | passed | 0.00046 seconds | +./spec/lib/aws_config_spec.rb[1:1:3:1] | passed | 0.00042 seconds | +./spec/lib/aws_config_spec.rb[1:1:4:1] | passed | 0.0001 seconds | +./spec/lib/aws_config_spec.rb[1:1:4:2] | passed | 0.0001 seconds | +./spec/lib/aws_config_spec.rb[1:1:4:3] | passed | 0.0002 seconds | +./spec/lib/aws_config_spec.rb[1:1:4:4] | passed | 0.03053 seconds | +./spec/lib/aws_config_spec.rb[1:1:5:1] | passed | 0.00014 seconds | +./spec/lib/aws_config_spec.rb[1:1:5:2] | passed | 0.00012 seconds | ./spec/lib/aws_config_spec.rb[1:2:1:1] | passed | 0.00003 seconds | ./spec/lib/aws_config_spec.rb[1:2:2:1] | passed | 0.00003 seconds | -./spec/lib/aws_config_spec.rb[1:2:3:1] | passed | 0.00004 seconds | -./spec/lib/aws_config_spec.rb[1:2:4:1] | passed | 0.00064 seconds | +./spec/lib/aws_config_spec.rb[1:2:3:1] | passed | 0.00005 seconds | +./spec/lib/aws_config_spec.rb[1:2:4:1] | passed | 0.00002 seconds | ./spec/lib/aws_config_spec.rb[1:3:1:1] | passed | 0.00003 seconds | ./spec/lib/aws_config_spec.rb[1:3:2:1] | passed | 0.00003 seconds | ./spec/lib/aws_config_spec.rb[1:3:3:1] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:1:1:1] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:1:1:2] | passed | 0.00004 seconds | -./spec/lib/retry_handler_spec.rb[1:1:1:3] | passed | 0.00009 seconds | +./spec/lib/retry_handler_spec.rb[1:1:1:1] | passed | 0.00039 seconds | +./spec/lib/retry_handler_spec.rb[1:1:1:2] | passed | 0.00028 seconds | +./spec/lib/retry_handler_spec.rb[1:1:1:3] | passed | 0.00083 seconds | ./spec/lib/retry_handler_spec.rb[1:1:2:1] | passed | 3.01 seconds | -./spec/lib/retry_handler_spec.rb[1:1:2:2] | passed | 1 second | +./spec/lib/retry_handler_spec.rb[1:1:2:2] | passed | 1.01 seconds | ./spec/lib/retry_handler_spec.rb[1:1:2:3] | passed | 1.01 seconds | -./spec/lib/retry_handler_spec.rb[1:1:2:4] | passed | 1 second | +./spec/lib/retry_handler_spec.rb[1:1:2:4] | passed | 1.01 seconds | ./spec/lib/retry_handler_spec.rb[1:1:2:5] | passed | 1.01 seconds | -./spec/lib/retry_handler_spec.rb[1:1:2:6] | passed | 1 second | -./spec/lib/retry_handler_spec.rb[1:1:2:7] | passed | 1.01 seconds | +./spec/lib/retry_handler_spec.rb[1:1:2:6] | passed | 1.01 seconds | +./spec/lib/retry_handler_spec.rb[1:1:2:7] | passed | 1 second | ./spec/lib/retry_handler_spec.rb[1:1:2:8] | passed | 1.01 seconds | -./spec/lib/retry_handler_spec.rb[1:1:2:9] | passed | 0.00018 seconds | +./spec/lib/retry_handler_spec.rb[1:1:2:9] | passed | 0.00066 seconds | ./spec/lib/retry_handler_spec.rb[1:1:2:10] | passed | 3.01 seconds | ./spec/lib/retry_handler_spec.rb[1:1:3:1] | passed | 3.01 seconds | -./spec/lib/retry_handler_spec.rb[1:1:3:2] | passed | 3.01 seconds | +./spec/lib/retry_handler_spec.rb[1:1:3:2] | passed | 3 seconds | ./spec/lib/retry_handler_spec.rb[1:1:3:3] | passed | 1 second | -./spec/lib/retry_handler_spec.rb[1:1:4:1] | passed | 0.00026 seconds | -./spec/lib/retry_handler_spec.rb[1:1:4:2] | passed | 0.00045 seconds | -./spec/lib/retry_handler_spec.rb[1:1:5:1] | passed | 0.00028 seconds | -./spec/lib/retry_handler_spec.rb[1:1:5:2] | passed | 0.00019 seconds | -./spec/lib/retry_handler_spec.rb[1:1:5:3] | passed | 0.00067 seconds | +./spec/lib/retry_handler_spec.rb[1:1:4:1] | passed | 0.00007 seconds | +./spec/lib/retry_handler_spec.rb[1:1:4:2] | passed | 0.00025 seconds | +./spec/lib/retry_handler_spec.rb[1:1:5:1] | passed | 0.00047 seconds | +./spec/lib/retry_handler_spec.rb[1:1:5:2] | passed | 0.00013 seconds | +./spec/lib/retry_handler_spec.rb[1:1:5:3] | passed | 0.00015 seconds | ./spec/lib/retry_handler_spec.rb[1:1:6:1] | passed | 15.01 seconds | -./spec/lib/retry_handler_spec.rb[1:1:6:2] | passed | 0.00047 seconds | -./spec/lib/retry_handler_spec.rb[1:1:6:3] | passed | 1 second | -./spec/lib/retry_handler_spec.rb[1:1:7:1] | passed | 1 second | +./spec/lib/retry_handler_spec.rb[1:1:6:2] | passed | 0.00138 seconds | +./spec/lib/retry_handler_spec.rb[1:1:6:3] | passed | 1.01 seconds | +./spec/lib/retry_handler_spec.rb[1:1:7:1] | passed | 1.01 seconds | ./spec/lib/retry_handler_spec.rb[1:2:1:1] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:2:1:2] | passed | 0.00003 seconds | +./spec/lib/retry_handler_spec.rb[1:2:1:2] | passed | 0.00002 seconds | ./spec/lib/retry_handler_spec.rb[1:2:1:3] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:1:4] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:2:1] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:2:2] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:2:3] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:2:4] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:2:2:5] | passed | 0.00003 seconds | +./spec/lib/retry_handler_spec.rb[1:2:2:5] | passed | 0.00005 seconds | ./spec/lib/retry_handler_spec.rb[1:2:3:1] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:2:3:2] | passed | 0.00003 seconds | +./spec/lib/retry_handler_spec.rb[1:2:3:2] | passed | 0.00002 seconds | ./spec/lib/retry_handler_spec.rb[1:2:3:3] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:3:4] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:3:5] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:2:3:6] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:3:1] | passed | 0.00013 seconds | -./spec/lib/retry_handler_spec.rb[1:3:2] | passed | 0.00008 seconds | -./spec/lib/retry_handler_spec.rb[1:3:3] | passed | 0.00008 seconds | -./spec/lib/retry_handler_spec.rb[1:3:4] | passed | 0.00009 seconds | -./spec/lib/retry_handler_spec.rb[1:3:5] | passed | 0.00008 seconds | -./spec/lib/retry_handler_spec.rb[1:4:1:1] | passed | 0.00011 seconds | -./spec/lib/retry_handler_spec.rb[1:4:1:2] | passed | 0.00007 seconds | +./spec/lib/retry_handler_spec.rb[1:3:1] | passed | 0.00022 seconds | +./spec/lib/retry_handler_spec.rb[1:3:2] | passed | 0.00016 seconds | +./spec/lib/retry_handler_spec.rb[1:3:3] | passed | 0.00105 seconds | +./spec/lib/retry_handler_spec.rb[1:3:4] | passed | 0.00044 seconds | +./spec/lib/retry_handler_spec.rb[1:3:5] | passed | 0.00015 seconds | +./spec/lib/retry_handler_spec.rb[1:4:1:1] | passed | 0.00009 seconds | +./spec/lib/retry_handler_spec.rb[1:4:1:2] | passed | 0.00006 seconds | ./spec/lib/retry_handler_spec.rb[1:4:2:1] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:4:2:2] | passed | 0.00004 seconds | -./spec/lib/retry_handler_spec.rb[1:5:1] | passed | 0.00004 seconds | +./spec/lib/retry_handler_spec.rb[1:4:2:2] | passed | 0.00003 seconds | +./spec/lib/retry_handler_spec.rb[1:5:1] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:5:2] | passed | 0.00004 seconds | ./spec/lib/retry_handler_spec.rb[1:6:1] | passed | 0.00003 seconds | ./spec/lib/retry_handler_spec.rb[1:6:2] | passed | 0.00002 seconds | ./spec/lib/retry_handler_spec.rb[1:6:3] | passed | 0.00002 seconds | ./spec/lib/retry_handler_spec.rb[1:6:4] | passed | 0.00003 seconds | -./spec/lib/retry_handler_spec.rb[1:6:5] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:1:1:1] | passed | 0.00269 seconds | -./spec/lib/s3_url_parser_spec.rb[1:1:1:2] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:1:1:3] | passed | 0.00002 seconds | +./spec/lib/retry_handler_spec.rb[1:6:5] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:1:1:1] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:1:1:2] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:1:1:3] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:1:1:4] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:1:1:5] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:1:1:6] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:1:2:1] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:1:2:1] | passed | 0.00004 seconds | ./spec/lib/s3_url_parser_spec.rb[1:1:2:2] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:1:2:3] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:1:2:3] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:1:2:4] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:2:1:1] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:2:1:2] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:2:1:3] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:2:1:4] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:2:2:1] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:2:2:2] | passed | 0.00004 seconds | +./spec/lib/s3_url_parser_spec.rb[1:2:2:1] | passed | 0.00003 seconds | +./spec/lib/s3_url_parser_spec.rb[1:2:2:2] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:2:2:3] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:3:1:1] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:3:1:2] | passed | 0.00002 seconds | @@ -557,66 +557,66 @@ example_id | status | run_time ./spec/lib/s3_url_parser_spec.rb[1:3:2:1] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:3:2:2] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:3:2:3] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:4:1:1] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:4:1:1] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:4:1:2] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:4:1:3] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:4:2:1] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:4:2:2] | passed | 0.00002 seconds | ./spec/lib/s3_url_parser_spec.rb[1:4:2:3] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:1:1] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:5:1:2] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:5:1:3] | passed | 0.00003 seconds | +./spec/lib/s3_url_parser_spec.rb[1:5:1:2] | passed | 0.00004 seconds | +./spec/lib/s3_url_parser_spec.rb[1:5:1:3] | passed | 0.00004 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:1:4] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:1:5] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:5:2:1] | passed | 0.00003 seconds | +./spec/lib/s3_url_parser_spec.rb[1:5:2:1] | passed | 0.00004 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:2:2] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:2:3] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:5:2:4] | passed | 0.00004 seconds | -./spec/lib/s3_url_parser_spec.rb[1:5:2:5] | passed | 0.00004 seconds | +./spec/lib/s3_url_parser_spec.rb[1:5:2:4] | passed | 0.00003 seconds | +./spec/lib/s3_url_parser_spec.rb[1:5:2:5] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:2:6] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:3:1] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:5:3:2] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:5:3:2] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:3:3] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:3:4] | passed | 0.00004 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:3:5] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:4:1] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:5:4:2] | passed | 0.00005 seconds | -./spec/lib/s3_url_parser_spec.rb[1:6:1:1] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:6:1:1] | passed | 0.00003 seconds | ./spec/lib/s3_url_parser_spec.rb[1:6:1:2] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:6:1:3] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:6:2:1] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:6:2:2] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:6:2:3] | passed | 0.00002 seconds | -./spec/lib/s3_url_parser_spec.rb[1:7:1] | passed | 0.00004 seconds | -./spec/lib/s3_url_parser_spec.rb[1:7:2] | passed | 0.00003 seconds | -./spec/lib/s3_url_parser_spec.rb[1:8:1:1] | passed | 0.00005 seconds | -./spec/lib/s3_url_parser_spec.rb[1:8:2:1] | passed | 0.00003 seconds | +./spec/lib/s3_url_parser_spec.rb[1:6:1:3] | passed | 0.00002 seconds | +./spec/lib/s3_url_parser_spec.rb[1:6:2:1] | passed | 0.00004 seconds | +./spec/lib/s3_url_parser_spec.rb[1:6:2:2] | passed | 0.00003 seconds | +./spec/lib/s3_url_parser_spec.rb[1:6:2:3] | passed | 0.00033 seconds | +./spec/lib/s3_url_parser_spec.rb[1:7:1] | passed | 0.00036 seconds | +./spec/lib/s3_url_parser_spec.rb[1:7:2] | passed | 0.00029 seconds | +./spec/lib/s3_url_parser_spec.rb[1:8:1:1] | passed | 0.00003 seconds | +./spec/lib/s3_url_parser_spec.rb[1:8:2:1] | passed | 0.00005 seconds | ./spec/lib/url_utils_spec.rb[1:1:1:1] | passed | 0.00003 seconds | ./spec/lib/url_utils_spec.rb[1:1:1:2] | passed | 0.00003 seconds | ./spec/lib/url_utils_spec.rb[1:1:1:3] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:1:1:4] | passed | 0.00004 seconds | -./spec/lib/url_utils_spec.rb[1:1:1:5] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:1:1:5] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:1:1:6] | passed | 0.00003 seconds | -./spec/lib/url_utils_spec.rb[1:1:1:7] | passed | 0.00005 seconds | -./spec/lib/url_utils_spec.rb[1:1:1:8] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:1:1:7] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:1:1:8] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:1:1:9] | passed | 0.00003 seconds | -./spec/lib/url_utils_spec.rb[1:1:2:1] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:1:2:1] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:1:2:2] | passed | 0.00003 seconds | -./spec/lib/url_utils_spec.rb[1:1:2:3] | passed | 0.00005 seconds | -./spec/lib/url_utils_spec.rb[1:1:2:4] | passed | 0.00003 seconds | -./spec/lib/url_utils_spec.rb[1:1:2:5] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:1:2:3] | passed | 0.00004 seconds | +./spec/lib/url_utils_spec.rb[1:1:2:4] | passed | 0.00004 seconds | +./spec/lib/url_utils_spec.rb[1:1:2:5] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:1:3:1] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:1:3:2] | passed | 0.00004 seconds | -./spec/lib/url_utils_spec.rb[1:1:3:3] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:1:3:3] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:1:3:4] | passed | 0.00004 seconds | ./spec/lib/url_utils_spec.rb[1:2:1:1] | passed | 0.00002 seconds | -./spec/lib/url_utils_spec.rb[1:2:1:2] | passed | 0.00004 seconds | -./spec/lib/url_utils_spec.rb[1:2:1:3] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:2:1:2] | passed | 0.00002 seconds | +./spec/lib/url_utils_spec.rb[1:2:1:3] | passed | 0.00002 seconds | ./spec/lib/url_utils_spec.rb[1:2:1:4] | passed | 0.00002 seconds | ./spec/lib/url_utils_spec.rb[1:2:1:5] | passed | 0.00003 seconds | ./spec/lib/url_utils_spec.rb[1:2:2:1] | passed | 0.00002 seconds | ./spec/lib/url_utils_spec.rb[1:2:2:2] | passed | 0.00002 seconds | ./spec/lib/url_utils_spec.rb[1:2:2:3] | passed | 0.00002 seconds | ./spec/lib/url_utils_spec.rb[1:2:2:4] | passed | 0.00002 seconds | -./spec/lib/url_utils_spec.rb[1:2:3:1] | passed | 0.00002 seconds | -./spec/lib/url_utils_spec.rb[1:2:3:2] | passed | 0.00002 seconds | +./spec/lib/url_utils_spec.rb[1:2:3:1] | passed | 0.00003 seconds | +./spec/lib/url_utils_spec.rb[1:2:3:2] | passed | 0.00003 seconds | diff --git a/scripts/README.md b/scripts/README.md index 1af9e24..93e1111 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -53,29 +53,61 @@ Options: ./scripts/generate_jwt_token.rb --secret-name my-app/jwt-secret --region us-west-2 ``` -### 2. Generate Pre-signed S3 URLs +### 2. Setup IAM Role -Generate pre-signed S3 URLs for source PDF and destination folder: +Create an IAM role that clients can assume to access S3 buckets: ```bash -./scripts/generate_presigned_urls.rb \ - --bucket my-bucket \ - --source-key pdfs/test.pdf \ - --dest-prefix output/ +./scripts/setup_iam_role.rb \ + --source-bucket my-source-bucket \ + --dest-bucket my-dest-bucket ``` +This script creates a role named `PdfConverterClientRole` with: +- Read permissions on source bucket(s) +- Write permissions on destination bucket(s) +- Trust policy requiring ExternalId `pdf-converter-client` + **Required Arguments:** -- `--bucket BUCKET`: S3 bucket name -- `--source-key KEY`: S3 key for source PDF (e.g., 'pdfs/test.pdf') -- `--dest-prefix PREFIX`: S3 prefix for destination images (e.g., 'output/') +- `--source-bucket BUCKET`: Source S3 bucket (can specify multiple times) +- `--dest-bucket BUCKET`: Destination S3 bucket (can specify multiple times) + +**Examples:** + +```bash +# Create role with single source and destination bucket +./scripts/setup_iam_role.rb \ + --source-bucket my-pdfs \ + --dest-bucket my-images + +# Create role with multiple buckets +./scripts/setup_iam_role.rb \ + --source-bucket bucket-1 \ + --source-bucket bucket-2 \ + --dest-bucket bucket-3 +``` + +### 3. Generate STS Credentials + +Generate temporary AWS credentials by assuming the IAM role: + +```bash +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole +``` + +The script assumes the role and returns temporary credentials (valid for 15 minutes) that can be used to call the API. + +**Required Arguments:** + +- `--role-arn ARN`: IAM role ARN to assume **Optional Arguments:** ```bash -r, --region REGION AWS region (default: us-east-1) - -e, --expiration SECONDS URL expiration in seconds (default: 3600) - -u, --unique-id ID Unique ID for this conversion (default: test-TIMESTAMP) + -s, --session-name NAME Role session name (default: pdf-converter-TIMESTAMP) -f, --format FORMAT Output format: pretty, json, curl (default: pretty) -h, --help Show help message ``` @@ -89,46 +121,44 @@ Generate pre-signed S3 URLs for source PDF and destination folder: **Examples:** ```bash -# Generate URLs with pretty output -./scripts/generate_presigned_urls.rb \ - --bucket my-bucket \ - --source-key pdfs/sample.pdf \ - --dest-prefix converted/ +# Generate credentials with pretty output +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole -# Generate URLs as JSON -./scripts/generate_presigned_urls.rb \ - --bucket my-bucket \ - --source-key pdfs/sample.pdf \ - --dest-prefix converted/ \ +# Generate credentials as JSON +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole \ --format json -# Generate URLs with curl template -./scripts/generate_presigned_urls.rb \ - --bucket my-bucket \ - --source-key pdfs/sample.pdf \ - --dest-prefix converted/ \ +# Generate curl command template +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole \ --format curl - -# Custom expiration and unique ID -./scripts/generate_presigned_urls.rb \ - --bucket my-bucket \ - --source-key pdfs/sample.pdf \ - --dest-prefix converted/ \ - --expiration 7200 \ - --unique-id my-test-123 ``` ## Complete Testing Workflow Here's how to test your deployed API end-to-end: -### Step 1: Upload a test PDF to S3 +### Step 1: Set up IAM Role (one-time setup) + +Create an IAM role for testing: + +```bash +./scripts/setup_iam_role.rb \ + --source-bucket my-bucket \ + --dest-bucket my-bucket +``` + +Note the role ARN from the output (e.g., `arn:aws:iam::123456789012:role/PdfConverterClientRole`). + +### Step 2: Upload a test PDF to S3 ```bash aws s3 cp test.pdf s3://my-bucket/pdfs/test.pdf ``` -### Step 2: Generate a JWT token +### Step 3: Generate a JWT token ```bash ./scripts/generate_jwt_token.rb @@ -136,18 +166,16 @@ aws s3 cp test.pdf s3://my-bucket/pdfs/test.pdf Copy the token from the output. -### Step 3: Generate pre-signed URLs +### Step 4: Generate STS credentials ```bash -./scripts/generate_presigned_urls.rb \ - --bucket my-bucket \ - --source-key pdfs/test.pdf \ - --dest-prefix output/ +./scripts/generate_sts_credentials.rb \ + --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole ``` -Copy the JSON payload from the output. +Copy the JSON payload from the output and update the bucket/key values. -### Step 4: Call the API +### Step 5: Call the API Use the JWT token and JSON payload to call your deployed API: @@ -156,13 +184,24 @@ curl -X POST https://your-api-id.execute-api.us-east-1.amazonaws.com/Prod/conver -H "Authorization: Bearer YOUR_JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "source": "https://s3.amazonaws.com/...", - "destination": "https://s3.amazonaws.com/...", + "source": { + "bucket": "my-bucket", + "key": "pdfs/test.pdf" + }, + "destination": { + "bucket": "my-bucket", + "prefix": "output/" + }, + "credentials": { + "accessKeyId": "ASIA...", + "secretAccessKey": "...", + "sessionToken": "..." + }, "unique_id": "test-123" }' ``` -### Step 5: Check the results +### Step 6: Check the results ```bash # List converted images @@ -190,9 +229,17 @@ Ensure the JWT secret exists in AWS Secrets Manager: aws secretsmanager describe-secret --secret-id pdf-converter/jwt-secret ``` +### Unable to Assume Role + +Ensure: +1. The role ARN is correct +2. Your AWS credentials have permission to assume the role +3. The role's trust policy includes your AWS account and the correct ExternalId + ### Permission Denied -Your AWS user/role needs these permissions: -- `s3:GetObject` on the source bucket -- `s3:PutObject` on the destination bucket -- `secretsmanager:GetSecretValue` for the JWT secret +The temporary STS credentials need these permissions: +- `s3:GetObject` on the source bucket/key +- `s3:PutObject` on the destination bucket/prefix + +These permissions are scoped to the IAM role and configured when you run `setup_iam_role.rb`. diff --git a/scripts/generate_presigned_urls.rb b/scripts/generate_presigned_urls.rb deleted file mode 100755 index e257c3c..0000000 --- a/scripts/generate_presigned_urls.rb +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'bundler/inline' - -gemfile do - source 'https://rubygems.org' - gem 'aws-sdk-s3', '~> 1' - gem 'rexml' # Required by aws-sdk -end - -require 'optparse' -require 'json' - -# Script to generate pre-signed S3 URLs for testing the PDF Converter API -# Uses local AWS credentials from ~/.aws/credentials or environment variables - -class PresignedUrlGenerator - DEFAULT_EXPIRATION = 3600 # 1 hour - - def initialize(bucket:, region: 'us-east-1', expiration: DEFAULT_EXPIRATION) - @bucket = bucket - @region = region - @expiration = expiration - @s3_client = Aws::S3::Client.new(region: @region) - end - - # Generate a pre-signed GET URL for downloading the source PDF - def generate_source_url(key) - signer = Aws::S3::Presigner.new(client: @s3_client) - signer.presigned_url( - :get_object, - bucket: @bucket, - key: key, - expires_in: @expiration - ) - end - - # Generate a pre-signed PUT URL for uploading converted images - # The key should be a prefix/folder path ending with / - def generate_destination_url(prefix) - # Ensure prefix ends with / for folder-style access - prefix = prefix.end_with?('/') ? prefix : "#{prefix}/" - - signer = Aws::S3::Presigner.new(client: @s3_client) - signer.presigned_url( - :put_object, - bucket: @bucket, - key: "#{prefix}placeholder.png", # Example key, actual keys will be unique_id-N.png - expires_in: @expiration - ).gsub('placeholder.png', '') # Remove placeholder to get base URL - end - - # Generate both URLs and return as a hash - def generate_urls(source_key:, destination_prefix:, unique_id: 'test') - { - source: generate_source_url(source_key), - destination: generate_destination_url(destination_prefix), - unique_id: unique_id, - bucket: @bucket, - region: @region, - expiration: @expiration - } - end -end - -# Parse command line options -options = { - region: 'us-east-1', - expiration: PresignedUrlGenerator::DEFAULT_EXPIRATION, - unique_id: "test-#{Time.now.to_i}", - output_format: 'pretty' -} - -OptionParser.new do |opts| - opts.banner = "Usage: #{$PROGRAM_NAME} --bucket BUCKET --source-key KEY --dest-prefix PREFIX [options]" - opts.separator "" - opts.separator "Generate pre-signed S3 URLs for testing the PDF Converter API" - opts.separator "" - opts.separator "Required arguments:" - - opts.on("-b", "--bucket BUCKET", "S3 bucket name") do |v| - options[:bucket] = v - end - - opts.on("-s", "--source-key KEY", "S3 key for source PDF (e.g., 'pdfs/test.pdf')") do |v| - options[:source_key] = v - end - - opts.on("-d", "--dest-prefix PREFIX", "S3 prefix for destination images (e.g., 'output/')") do |v| - options[:dest_prefix] = v - end - - opts.separator "" - opts.separator "Optional arguments:" - - opts.on("-r", "--region REGION", "AWS region (default: us-east-1)") do |v| - options[:region] = v - end - - opts.on("-e", "--expiration SECONDS", Integer, "URL expiration in seconds (default: 3600)") do |v| - options[:expiration] = v - end - - opts.on("-u", "--unique-id ID", "Unique ID for this conversion (default: test-TIMESTAMP)") do |v| - options[:unique_id] = v - end - - opts.on("-f", "--format FORMAT", "Output format: pretty, json, curl (default: pretty)") do |v| - options[:output_format] = v - end - - opts.on("-h", "--help", "Show this help message") do - puts opts - exit - end -end.parse! - -# Validate required arguments -unless options[:bucket] && options[:source_key] && options[:dest_prefix] - puts "Error: --bucket, --source-key, and --dest-prefix are required" - puts "Run with --help for usage information" - exit 1 -end - -# Generate URLs -begin - generator = PresignedUrlGenerator.new( - bucket: options[:bucket], - region: options[:region], - expiration: options[:expiration] - ) - - urls = generator.generate_urls( - source_key: options[:source_key], - destination_prefix: options[:dest_prefix], - unique_id: options[:unique_id] - ) - - # Output based on format - case options[:output_format] - when 'json' - puts JSON.pretty_generate(urls) - when 'curl' - # Output a ready-to-use curl command (requires JWT token to be added) - puts "# Copy this curl command and replace YOUR_JWT_TOKEN with an actual token" - puts "curl -X POST YOUR_API_ENDPOINT \\" - puts " -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\" - puts " -H \"Content-Type: application/json\" \\" - puts " -d '{" - puts " \"source\": \"#{urls[:source]}\"," - puts " \"destination\": \"#{urls[:destination]}\"," - puts " \"unique_id\": \"#{urls[:unique_id]}\"" - puts " }'" - else # pretty - puts "=" * 80 - puts "Pre-signed S3 URLs Generated" - puts "=" * 80 - puts "" - puts "Source URL (GET):" - puts " #{urls[:source]}" - puts "" - puts "Destination URL (PUT):" - puts " #{urls[:destination]}" - puts "" - puts "Details:" - puts " Bucket: #{urls[:bucket]}" - puts " Region: #{urls[:region]}" - puts " Unique ID: #{urls[:unique_id]}" - puts " Expires in: #{urls[:expiration]} seconds (#{urls[:expiration] / 60} minutes)" - puts "" - puts "JSON Payload for API:" - puts JSON.pretty_generate({ - source: urls[:source], - destination: urls[:destination], - unique_id: urls[:unique_id] - }) - puts "" - puts "=" * 80 - end - -rescue Aws::Errors::ServiceError => e - puts "AWS Error: #{e.message}" - puts "" - puts "Make sure you have:" - puts " 1. AWS credentials configured (run 'aws configure')" - puts " 2. Permissions to access S3 in region #{options[:region]}" - exit 1 -rescue StandardError => e - puts "Error: #{e.message}" - exit 1 -end diff --git a/scripts/generate_sts_credentials.rb b/scripts/generate_sts_credentials.rb new file mode 100755 index 0000000..fc1d02b --- /dev/null +++ b/scripts/generate_sts_credentials.rb @@ -0,0 +1,199 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile do + source 'https://rubygems.org' + gem 'aws-sdk-sts', '~> 1' +end + +require 'json' +require 'optparse' + +# Script to generate temporary AWS STS credentials for PDF Converter client testing +# Assumes the IAM role and returns credentials that can be used to call the API + +class StsCredentialGenerator + EXTERNAL_ID = 'pdf-converter-client' + DURATION_SECONDS = 900 # 15 minutes + + def initialize(role_arn:, region: 'us-east-1', session_name: nil) + @role_arn = role_arn + @region = region + @session_name = session_name || "pdf-converter-#{Time.now.to_i}" + @sts_client = Aws::STS::Client.new(region: @region) + end + + def generate + puts "Assuming role: #{@role_arn}" + puts "External ID: #{EXTERNAL_ID}" + puts "Session name: #{@session_name}" + puts "Duration: #{DURATION_SECONDS} seconds (#{DURATION_SECONDS / 60} minutes)" + puts "" + + response = @sts_client.assume_role( + role_arn: @role_arn, + role_session_name: @session_name, + external_id: EXTERNAL_ID, + duration_seconds: DURATION_SECONDS + ) + + credentials = response.credentials + + { + 'accessKeyId' => credentials.access_key_id, + 'secretAccessKey' => credentials.secret_access_key, + 'sessionToken' => credentials.session_token, + 'expiration' => credentials.expiration.iso8601 + } + rescue Aws::STS::Errors::AccessDenied => e + raise "Access denied: #{e.message}. Ensure your AWS credentials have permission to assume the role." + rescue Aws::Errors::ServiceError => e + raise "AWS Error: #{e.message}" + end +end + +# Parse options +options = { + role_arn: nil, + region: 'us-east-1', + format: 'pretty', + session_name: nil +} + +OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options]" + opts.separator "" + opts.separator "Generate temporary AWS STS credentials for PDF Converter client testing" + opts.separator "" + opts.separator "Options:" + + opts.on("-a", "--role-arn ARN", "IAM role ARN to assume (required)") do |v| + options[:role_arn] = v + end + + opts.on("-r", "--region REGION", "AWS region (default: us-east-1)") do |v| + options[:region] = v + end + + opts.on("-s", "--session-name NAME", "Role session name (default: pdf-converter-TIMESTAMP)") do |v| + options[:session_name] = v + end + + opts.on("-f", "--format FORMAT", "Output format: pretty, json, curl (default: pretty)") do |v| + unless %w[pretty json curl].include?(v) + puts "Error: Invalid format '#{v}'. Must be one of: pretty, json, curl" + exit 1 + end + options[:format] = v + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + puts "" + puts "Example:" + puts " #{$PROGRAM_NAME} \\" + puts " --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole" + puts "" + puts " # Generate credentials as JSON" + puts " #{$PROGRAM_NAME} \\" + puts " --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole \\" + puts " --format json" + puts "" + puts " # Generate curl command template" + puts " #{$PROGRAM_NAME} \\" + puts " --role-arn arn:aws:iam::123456789012:role/PdfConverterClientRole \\" + puts " --format curl" + exit + end +end.parse! + +# Validate +if options[:role_arn].nil? + puts "Error: --role-arn is required" + puts "Run with --help for usage information" + exit 1 +end + +begin + generator = StsCredentialGenerator.new( + role_arn: options[:role_arn], + region: options[:region], + session_name: options[:session_name] + ) + + credentials = generator.generate + + case options[:format] + when 'json' + puts JSON.pretty_generate(credentials) + + when 'curl' + puts "# Copy this curl command and replace placeholders with your values:" + puts "curl -X POST https://YOUR_API_ENDPOINT/convert \\" + puts " -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\" + puts " -H \"Content-Type: application/json\" \\" + puts " -d '{" + puts " \"source\": {" + puts " \"bucket\": \"YOUR_SOURCE_BUCKET\"," + puts " \"key\": \"path/to/your/file.pdf\"" + puts " }," + puts " \"destination\": {" + puts " \"bucket\": \"YOUR_DEST_BUCKET\"," + puts " \"prefix\": \"output/\"" + puts " }," + puts " \"credentials\": {" + puts " \"accessKeyId\": \"#{credentials['accessKeyId']}\"," + puts " \"secretAccessKey\": \"#{credentials['secretAccessKey']}\"," + puts " \"sessionToken\": \"#{credentials['sessionToken']}\"" + puts " }," + puts " \"unique_id\": \"test-#{Time.now.to_i}\"," + puts " \"webhook\": \"https://YOUR_WEBHOOK_URL\" (optional)" + puts " }'" + puts "" + puts "# Credentials expire at: #{credentials['expiration']}" + + else # pretty + puts "=" * 80 + puts "✅ STS Credentials Generated Successfully" + puts "=" * 80 + puts "" + puts "Credentials (expires at #{credentials['expiration']}):" + puts "" + puts "JSON Payload for API Request:" + puts "" + puts "{" + puts " \"source\": {" + puts " \"bucket\": \"YOUR_SOURCE_BUCKET\"," + puts " \"key\": \"path/to/your/file.pdf\"" + puts " }," + puts " \"destination\": {" + puts " \"bucket\": \"YOUR_DEST_BUCKET\"," + puts " \"prefix\": \"output/\"" + puts " }," + puts " \"credentials\": {" + puts " \"accessKeyId\": \"#{credentials['accessKeyId']}\"," + puts " \"secretAccessKey\": \"#{credentials['secretAccessKey']}\"," + puts " \"sessionToken\": \"#{credentials['sessionToken']}\"" + puts " }," + puts " \"unique_id\": \"test-#{Time.now.to_i}\"," + puts " \"webhook\": \"https://your-webhook-url.com\" (optional)" + puts "}" + puts "" + puts "=" * 80 + puts "" + puts "Use --format json for JSON output" + puts "Use --format curl for a ready-to-use curl command" + end + +rescue StandardError => e + puts "Error: #{e.message}" + puts "" + puts "Troubleshooting:" + puts " 1. Ensure AWS credentials are configured (run 'aws configure')" + puts " 2. Verify the role ARN is correct" + puts " 3. Check that your AWS user/role has permission to assume the target role" + puts " 4. Verify the ExternalId '#{StsCredentialGenerator::EXTERNAL_ID}' matches the role's trust policy" + exit 1 +end diff --git a/scripts/setup_iam_role.rb b/scripts/setup_iam_role.rb new file mode 100755 index 0000000..577713e --- /dev/null +++ b/scripts/setup_iam_role.rb @@ -0,0 +1,238 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile do + source 'https://rubygems.org' + gem 'aws-sdk-iam', '~> 1' + gem 'aws-sdk-sts', '~> 1' +end + +require 'json' +require 'optparse' + +# Script to create IAM role for PDF Converter clients +# This role allows clients to access S3 buckets for PDF conversion + +class IamRoleSetup + ROLE_NAME = 'PdfConverterClientRole' + EXTERNAL_ID = 'pdf-converter-client' + + def initialize(account_id:, source_buckets:, dest_buckets:) + @account_id = account_id + @source_buckets = source_buckets + @dest_buckets = dest_buckets + @iam_client = Aws::IAM::Client.new + end + + def setup + puts "Setting up IAM role: #{ROLE_NAME}" + puts "Account ID: #{@account_id}" + puts "Source buckets: #{@source_buckets.join(', ')}" + puts "Destination buckets: #{@dest_buckets.join(', ')}" + puts "" + + # Check if role exists + if role_exists? + puts "⚠️ Role #{ROLE_NAME} already exists" + print "Delete and recreate? (y/N): " + response = gets.chomp.downcase + if response == 'y' + delete_role + else + puts "Exiting without changes" + return + end + end + + # Create role + create_role + attach_policy + + role_arn = "arn:aws:iam::#{@account_id}:role/#{ROLE_NAME}" + + puts "" + puts "=" * 80 + puts "✅ IAM Role Setup Complete" + puts "=" * 80 + puts "" + puts "Role ARN:" + puts " #{role_arn}" + puts "" + puts "External ID (required when assuming role):" + puts " #{EXTERNAL_ID}" + puts "" + puts "To assume this role:" + puts " aws sts assume-role \\" + puts " --role-arn #{role_arn} \\" + puts " --role-session-name pdf-converter-session \\" + puts " --external-id #{EXTERNAL_ID} \\" + puts " --duration-seconds 900" + puts "" + puts "Or use: ./scripts/generate_sts_credentials.rb --role-arn #{role_arn}" + puts "" + puts "=" * 80 + end + + private + + def role_exists? + @iam_client.get_role(role_name: ROLE_NAME) + true + rescue Aws::IAM::Errors::NoSuchEntity + false + end + + def delete_role + puts "Deleting existing role..." + + # Delete inline policies + @iam_client.list_role_policies(role_name: ROLE_NAME).policy_names.each do |policy_name| + @iam_client.delete_role_policy(role_name: ROLE_NAME, policy_name: policy_name) + end + + # Detach managed policies + @iam_client.list_attached_role_policies(role_name: ROLE_NAME).attached_policies.each do |policy| + @iam_client.detach_role_policy(role_name: ROLE_NAME, policy_arn: policy.policy_arn) + end + + # Delete role + @iam_client.delete_role(role_name: ROLE_NAME) + puts "✅ Existing role deleted" + end + + def create_role + puts "Creating IAM role..." + + trust_policy = { + "Version" => "2012-10-17", + "Statement" => [ + { + "Effect" => "Allow", + "Principal" => { + "AWS" => "arn:aws:iam::#{@account_id}:root" + }, + "Action" => "sts:AssumeRole", + "Condition" => { + "StringEquals" => { + "sts:ExternalId" => EXTERNAL_ID + } + } + } + ] + } + + @iam_client.create_role( + role_name: ROLE_NAME, + assume_role_policy_document: trust_policy.to_json, + description: 'Role for PDF Converter clients to access S3' + ) + + puts "✅ Role created" + end + + def attach_policy + puts "Attaching permissions policy..." + + statements = [] + + # Add read permissions for source buckets + @source_buckets.each do |bucket| + statements << { + "Sid" => "ReadSource#{bucket.gsub(/[^a-zA-Z0-9]/, '')}", + "Effect" => "Allow", + "Action" => "s3:GetObject", + "Resource" => "arn:aws:s3:::#{bucket}/*" + } + end + + # Add write permissions for destination buckets + @dest_buckets.each do |bucket| + statements << { + "Sid" => "WriteDest#{bucket.gsub(/[^a-zA-Z0-9]/, '')}", + "Effect" => "Allow", + "Action" => "s3:PutObject", + "Resource" => "arn:aws:s3:::#{bucket}/*" + } + end + + permissions_policy = { + "Version" => "2012-10-17", + "Statement" => statements + } + + @iam_client.put_role_policy( + role_name: ROLE_NAME, + policy_name: 'S3AccessPolicy', + policy_document: permissions_policy.to_json + ) + + puts "✅ Policy attached" + end +end + +# Get current account ID +def get_account_id + sts = Aws::STS::Client.new + sts.get_caller_identity.account +end + +# Parse options +options = { + source_buckets: [], + dest_buckets: [] +} + +OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options]" + opts.separator "" + opts.separator "Create IAM role for PDF Converter client S3 access" + opts.separator "" + opts.separator "Options:" + + opts.on("-s", "--source-bucket BUCKET", "Source S3 bucket (can specify multiple times)") do |v| + options[:source_buckets] << v + end + + opts.on("-d", "--dest-bucket BUCKET", "Destination S3 bucket (can specify multiple times)") do |v| + options[:dest_buckets] << v + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + puts "" + puts "Example:" + puts " #{$PROGRAM_NAME} \\" + puts " --source-bucket my-pdfs \\" + puts " --dest-bucket my-converted-images" + exit + end +end.parse! + +# Validate +if options[:source_buckets].empty? || options[:dest_buckets].empty? + puts "Error: At least one source bucket and one destination bucket required" + puts "Run with --help for usage information" + exit 1 +end + +begin + account_id = get_account_id + setup = IamRoleSetup.new( + account_id: account_id, + source_buckets: options[:source_buckets], + dest_buckets: options[:dest_buckets] + ) + setup.setup +rescue Aws::Errors::ServiceError => e + puts "AWS Error: #{e.message}" + puts "" + puts "Make sure you have:" + puts " 1. AWS credentials configured (run 'aws configure')" + puts " 2. IAM permissions to create roles and policies" + exit 1 +rescue StandardError => e + puts "Error: #{e.message}" + exit 1 +end