|
| 1 | +/** |
| 2 | + * Integration tests for BucketProvisioner against a real MinIO instance. |
| 3 | + * |
| 4 | + * These tests exercise the full provisioning pipeline end-to-end: |
| 5 | + * 1. provision() — create bucket, set policies, CORS, versioning, lifecycle |
| 6 | + * 2. inspect() — read back all bucket config and verify it matches |
| 7 | + * 3. updateCors() — change CORS rules on an existing bucket |
| 8 | + * 4. bucketExists() — verify bucket existence checks |
| 9 | + * |
| 10 | + * Requires MinIO running on localhost:9000 (docker-compose or CI service). |
| 11 | + * Skips gracefully when MinIO is not reachable. |
| 12 | + * |
| 13 | + * NOTE: MinIO free / edge-cicd does NOT support several S3 APIs: |
| 14 | + * - PutBucketCors / GetBucketCors (paid AIStor feature) |
| 15 | + * - PutPublicAccessBlock / GetPublicAccessBlock |
| 16 | + * - PutBucketPolicy (may partially work) |
| 17 | + * - PutBucketVersioning (edge-cicd) |
| 18 | + * - PutBucketLifecycleConfiguration (edge-cicd) |
| 19 | + * The provisioner gracefully degrades via error-code matching (XmlParseException, |
| 20 | + * NotImplemented, etc.), so provision() and updateCors() succeed but these |
| 21 | + * features are not actually applied on MinIO free. |
| 22 | + * Tests verify the graceful degradation path and focus on APIs MinIO supports: |
| 23 | + * bucket creation and bucket existence checks. |
| 24 | + */ |
| 25 | + |
| 26 | +import { BucketProvisioner } from '../src/provisioner'; |
| 27 | +import type { StorageConnectionConfig } from '../src/types'; |
| 28 | +import { ProvisionerError } from '../src/types'; |
| 29 | + |
| 30 | +// --- MinIO config (matches CI env) --- |
| 31 | + |
| 32 | +const MINIO_ENDPOINT = process.env.CDN_ENDPOINT || 'http://localhost:9000'; |
| 33 | +const AWS_REGION = process.env.AWS_REGION || 'us-east-1'; |
| 34 | +const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY || 'minioadmin'; |
| 35 | +const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY || 'minioadmin'; |
| 36 | + |
| 37 | +const connection: StorageConnectionConfig = { |
| 38 | + provider: 'minio', |
| 39 | + region: AWS_REGION, |
| 40 | + endpoint: MINIO_ENDPOINT, |
| 41 | + accessKeyId: AWS_ACCESS_KEY, |
| 42 | + secretAccessKey: AWS_SECRET_KEY, |
| 43 | +}; |
| 44 | + |
| 45 | +const TEST_ORIGINS = ['https://app.example.com']; |
| 46 | + |
| 47 | +jest.setTimeout(30000); |
| 48 | + |
| 49 | +// Unique prefix per test run to avoid bucket name collisions |
| 50 | +const RUN_ID = Date.now().toString(36); |
| 51 | + |
| 52 | +function testBucketName(suffix: string): string { |
| 53 | + return `bp-test-${RUN_ID}-${suffix}`; |
| 54 | +} |
| 55 | + |
| 56 | +/** |
| 57 | + * Check if MinIO is reachable. Skips the entire suite if not. |
| 58 | + */ |
| 59 | +async function isMinioReachable(): Promise<boolean> { |
| 60 | + try { |
| 61 | + const response = await fetch(`${MINIO_ENDPOINT}/minio/health/live`, { |
| 62 | + signal: AbortSignal.timeout(3000), |
| 63 | + }); |
| 64 | + return response.ok; |
| 65 | + } catch { |
| 66 | + return false; |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +// --- Conditional test runner --- |
| 71 | +// If MinIO is not available, all tests in this file pass instantly (early return). |
| 72 | + |
| 73 | +let minioAvailable = false; |
| 74 | + |
| 75 | +beforeAll(async () => { |
| 76 | + minioAvailable = await isMinioReachable(); |
| 77 | + if (!minioAvailable) { |
| 78 | + // eslint-disable-next-line no-console |
| 79 | + console.warn( |
| 80 | + 'MinIO not reachable at %s — skipping bucket-provisioner integration tests', |
| 81 | + MINIO_ENDPOINT, |
| 82 | + ); |
| 83 | + } |
| 84 | +}); |
| 85 | + |
| 86 | +// --- Tests --- |
| 87 | + |
| 88 | +describe('BucketProvisioner integration (MinIO)', () => { |
| 89 | + let provisioner: BucketProvisioner; |
| 90 | + |
| 91 | + beforeAll(() => { |
| 92 | + if (!minioAvailable) return; |
| 93 | + provisioner = new BucketProvisioner({ |
| 94 | + connection, |
| 95 | + allowedOrigins: TEST_ORIGINS, |
| 96 | + }); |
| 97 | + }); |
| 98 | + |
| 99 | + describe('provision — private bucket', () => { |
| 100 | + const bucketName = testBucketName('private'); |
| 101 | + |
| 102 | + it('should provision a private bucket successfully', async () => { |
| 103 | + if (!minioAvailable) return; |
| 104 | + |
| 105 | + const result = await provisioner.provision({ |
| 106 | + bucketName, |
| 107 | + accessType: 'private', |
| 108 | + }); |
| 109 | + |
| 110 | + // provision() return values reflect intent, not API reads |
| 111 | + expect(result.bucketName).toBe(bucketName); |
| 112 | + expect(result.accessType).toBe('private'); |
| 113 | + expect(result.provider).toBe('minio'); |
| 114 | + expect(result.region).toBe(AWS_REGION); |
| 115 | + expect(result.endpoint).toBe(MINIO_ENDPOINT); |
| 116 | + expect(result.blockPublicAccess).toBe(true); |
| 117 | + expect(result.versioning).toBe(false); |
| 118 | + expect(result.publicUrlPrefix).toBeNull(); |
| 119 | + expect(result.lifecycleRules).toHaveLength(0); |
| 120 | + // CORS rules are built and returned (intent), even though MinIO |
| 121 | + // may not actually apply them (PutBucketCors unsupported on free) |
| 122 | + expect(result.corsRules).toHaveLength(1); |
| 123 | + expect(result.corsRules[0].allowedOrigins).toEqual(TEST_ORIGINS); |
| 124 | + expect(result.corsRules[0].allowedMethods).toContain('PUT'); |
| 125 | + expect(result.corsRules[0].allowedMethods).toContain('HEAD'); |
| 126 | + expect(result.corsRules[0].allowedMethods).not.toContain('GET'); |
| 127 | + }); |
| 128 | + |
| 129 | + it('should be inspectable after provisioning', async () => { |
| 130 | + if (!minioAvailable) return; |
| 131 | + |
| 132 | + const inspected = await provisioner.inspect(bucketName, 'private'); |
| 133 | + |
| 134 | + expect(inspected.bucketName).toBe(bucketName); |
| 135 | + expect(inspected.accessType).toBe('private'); |
| 136 | + expect(inspected.versioning).toBe(false); |
| 137 | + // MinIO free doesn't support GetPublicAccessBlock — returns false |
| 138 | + expect(inspected.blockPublicAccess).toBe(false); |
| 139 | + // MinIO free doesn't support GetBucketCors — returns empty |
| 140 | + expect(inspected.corsRules).toHaveLength(0); |
| 141 | + }); |
| 142 | + |
| 143 | + it('should survive re-provisioning (idempotent)', async () => { |
| 144 | + if (!minioAvailable) return; |
| 145 | + |
| 146 | + const result = await provisioner.provision({ |
| 147 | + bucketName, |
| 148 | + accessType: 'private', |
| 149 | + }); |
| 150 | + |
| 151 | + expect(result.bucketName).toBe(bucketName); |
| 152 | + expect(result.accessType).toBe('private'); |
| 153 | + }); |
| 154 | + }); |
| 155 | + |
| 156 | + describe('provision — public bucket', () => { |
| 157 | + const bucketName = testBucketName('public'); |
| 158 | + |
| 159 | + it('should provision a public bucket without error', async () => { |
| 160 | + if (!minioAvailable) return; |
| 161 | + |
| 162 | + const result = await provisioner.provision({ |
| 163 | + bucketName, |
| 164 | + accessType: 'public', |
| 165 | + publicUrlPrefix: 'https://cdn.example.com', |
| 166 | + }); |
| 167 | + |
| 168 | + expect(result.bucketName).toBe(bucketName); |
| 169 | + expect(result.accessType).toBe('public'); |
| 170 | + expect(result.blockPublicAccess).toBe(false); |
| 171 | + expect(result.publicUrlPrefix).toBe('https://cdn.example.com'); |
| 172 | + expect(result.corsRules).toHaveLength(1); |
| 173 | + expect(result.corsRules[0].allowedMethods).toContain('PUT'); |
| 174 | + expect(result.corsRules[0].allowedMethods).toContain('GET'); |
| 175 | + expect(result.corsRules[0].allowedMethods).toContain('HEAD'); |
| 176 | + }); |
| 177 | + |
| 178 | + it('should be inspectable after provisioning', async () => { |
| 179 | + if (!minioAvailable) return; |
| 180 | + |
| 181 | + const inspected = await provisioner.inspect(bucketName, 'public'); |
| 182 | + |
| 183 | + expect(inspected.bucketName).toBe(bucketName); |
| 184 | + expect(inspected.accessType).toBe('public'); |
| 185 | + // MinIO free doesn't support CORS/policy reads |
| 186 | + expect(inspected.corsRules).toHaveLength(0); |
| 187 | + }); |
| 188 | + }); |
| 189 | + |
| 190 | + describe('provision — temp bucket', () => { |
| 191 | + const bucketName = testBucketName('temp'); |
| 192 | + |
| 193 | + it('should provision a temp bucket (lifecycle rules gracefully skipped on MinIO)', async () => { |
| 194 | + if (!minioAvailable) return; |
| 195 | + |
| 196 | + const result = await provisioner.provision({ |
| 197 | + bucketName, |
| 198 | + accessType: 'temp', |
| 199 | + }); |
| 200 | + |
| 201 | + expect(result.bucketName).toBe(bucketName); |
| 202 | + expect(result.accessType).toBe('temp'); |
| 203 | + expect(result.blockPublicAccess).toBe(true); |
| 204 | + expect(result.publicUrlPrefix).toBeNull(); |
| 205 | + // provision() returns intended lifecycle rules even though MinIO can't apply them |
| 206 | + expect(result.lifecycleRules).toHaveLength(1); |
| 207 | + expect(result.lifecycleRules[0].id).toBe('temp-cleanup'); |
| 208 | + expect(result.lifecycleRules[0].expirationDays).toBe(1); |
| 209 | + expect(result.lifecycleRules[0].enabled).toBe(true); |
| 210 | + }); |
| 211 | + |
| 212 | + it('should be inspectable (lifecycle not visible on MinIO free)', async () => { |
| 213 | + if (!minioAvailable) return; |
| 214 | + |
| 215 | + const inspected = await provisioner.inspect(bucketName, 'temp'); |
| 216 | + |
| 217 | + expect(inspected.bucketName).toBe(bucketName); |
| 218 | + // MinIO free doesn't support PutBucketLifecycleConfiguration — |
| 219 | + // the rules were gracefully skipped, so inspect() returns empty |
| 220 | + expect(inspected.lifecycleRules).toHaveLength(0); |
| 221 | + }); |
| 222 | + }); |
| 223 | + |
| 224 | + describe('provision — versioning', () => { |
| 225 | + const bucketName = testBucketName('versioned'); |
| 226 | + |
| 227 | + it('should provision with versioning flag (gracefully skipped on MinIO)', async () => { |
| 228 | + if (!minioAvailable) return; |
| 229 | + |
| 230 | + const result = await provisioner.provision({ |
| 231 | + bucketName, |
| 232 | + accessType: 'private', |
| 233 | + versioning: true, |
| 234 | + }); |
| 235 | + |
| 236 | + // provision() returns intended config even though MinIO can't apply versioning |
| 237 | + expect(result.versioning).toBe(true); |
| 238 | + }); |
| 239 | + |
| 240 | + it('should report versioning state on inspect (not applied on MinIO)', async () => { |
| 241 | + if (!minioAvailable) return; |
| 242 | + |
| 243 | + const inspected = await provisioner.inspect(bucketName, 'private'); |
| 244 | + // MinIO free doesn't support PutBucketVersioning — gracefully skipped |
| 245 | + expect(inspected.versioning).toBe(false); |
| 246 | + }); |
| 247 | + }); |
| 248 | + |
| 249 | + describe('provision — per-bucket CORS override', () => { |
| 250 | + const bucketName = testBucketName('custom-cors'); |
| 251 | + const customOrigins = ['https://custom.example.com', 'https://other.example.com']; |
| 252 | + |
| 253 | + it('should accept per-bucket allowedOrigins (returned in provision result)', async () => { |
| 254 | + if (!minioAvailable) return; |
| 255 | + |
| 256 | + const result = await provisioner.provision({ |
| 257 | + bucketName, |
| 258 | + accessType: 'private', |
| 259 | + allowedOrigins: customOrigins, |
| 260 | + }); |
| 261 | + |
| 262 | + // provision() returns the intended CORS rules |
| 263 | + expect(result.corsRules).toHaveLength(1); |
| 264 | + expect(result.corsRules[0].allowedOrigins).toEqual(customOrigins); |
| 265 | + }); |
| 266 | + |
| 267 | + it('should be inspectable (CORS not visible on MinIO free)', async () => { |
| 268 | + if (!minioAvailable) return; |
| 269 | + |
| 270 | + const inspected = await provisioner.inspect(bucketName, 'private'); |
| 271 | + // MinIO free doesn't support GetBucketCors |
| 272 | + expect(inspected.corsRules).toHaveLength(0); |
| 273 | + }); |
| 274 | + }); |
| 275 | + |
| 276 | + describe('updateCors', () => { |
| 277 | + const bucketName = testBucketName('cors-update'); |
| 278 | + |
| 279 | + beforeAll(async () => { |
| 280 | + if (!minioAvailable) return; |
| 281 | + await provisioner.provision({ |
| 282 | + bucketName, |
| 283 | + accessType: 'private', |
| 284 | + }); |
| 285 | + }); |
| 286 | + |
| 287 | + it('should return updated CORS rules (graceful degradation on MinIO)', async () => { |
| 288 | + if (!minioAvailable) return; |
| 289 | + |
| 290 | + const newOrigins = ['https://new-app.example.com']; |
| 291 | + const rules = await provisioner.updateCors({ |
| 292 | + bucketName, |
| 293 | + accessType: 'private', |
| 294 | + allowedOrigins: newOrigins, |
| 295 | + }); |
| 296 | + |
| 297 | + // updateCors() returns the intended rules even on MinIO |
| 298 | + expect(rules).toHaveLength(1); |
| 299 | + expect(rules[0].allowedOrigins).toEqual(newOrigins); |
| 300 | + expect(rules[0].allowedMethods).toContain('PUT'); |
| 301 | + expect(rules[0].allowedMethods).toContain('HEAD'); |
| 302 | + }); |
| 303 | + |
| 304 | + it('should switch from private to public CORS methods on access type change', async () => { |
| 305 | + if (!minioAvailable) return; |
| 306 | + |
| 307 | + const rules = await provisioner.updateCors({ |
| 308 | + bucketName, |
| 309 | + accessType: 'public', |
| 310 | + allowedOrigins: ['https://cdn.example.com'], |
| 311 | + }); |
| 312 | + |
| 313 | + expect(rules[0].allowedMethods).toContain('GET'); |
| 314 | + expect(rules[0].allowedMethods).toContain('PUT'); |
| 315 | + expect(rules[0].allowedMethods).toContain('HEAD'); |
| 316 | + }); |
| 317 | + }); |
| 318 | + |
| 319 | + describe('bucketExists', () => { |
| 320 | + const bucketName = testBucketName('exists-check'); |
| 321 | + |
| 322 | + beforeAll(async () => { |
| 323 | + if (!minioAvailable) return; |
| 324 | + await provisioner.provision({ |
| 325 | + bucketName, |
| 326 | + accessType: 'private', |
| 327 | + }); |
| 328 | + }); |
| 329 | + |
| 330 | + it('should return true for an existing bucket', async () => { |
| 331 | + if (!minioAvailable) return; |
| 332 | + |
| 333 | + const exists = await provisioner.bucketExists(bucketName); |
| 334 | + expect(exists).toBe(true); |
| 335 | + }); |
| 336 | + |
| 337 | + it('should return false for a non-existent bucket', async () => { |
| 338 | + if (!minioAvailable) return; |
| 339 | + |
| 340 | + const exists = await provisioner.bucketExists('does-not-exist-' + RUN_ID); |
| 341 | + expect(exists).toBe(false); |
| 342 | + }); |
| 343 | + }); |
| 344 | + |
| 345 | + describe('inspect — error handling', () => { |
| 346 | + it('should throw BUCKET_NOT_FOUND for non-existent bucket', async () => { |
| 347 | + if (!minioAvailable) return; |
| 348 | + |
| 349 | + await expect( |
| 350 | + provisioner.inspect('no-such-bucket-' + RUN_ID, 'private'), |
| 351 | + ).rejects.toThrow(ProvisionerError); |
| 352 | + |
| 353 | + await expect( |
| 354 | + provisioner.inspect('no-such-bucket-' + RUN_ID, 'private'), |
| 355 | + ).rejects.toThrow('does not exist'); |
| 356 | + }); |
| 357 | + }); |
| 358 | + |
| 359 | + describe('full round-trip: provision → inspect → updateCors → inspect', () => { |
| 360 | + const bucketName = testBucketName('roundtrip'); |
| 361 | + |
| 362 | + it('should complete the full workflow without error', async () => { |
| 363 | + if (!minioAvailable) return; |
| 364 | + |
| 365 | + // 1. Provision a private bucket with versioning |
| 366 | + const provisionResult = await provisioner.provision({ |
| 367 | + bucketName, |
| 368 | + accessType: 'private', |
| 369 | + versioning: true, |
| 370 | + }); |
| 371 | + |
| 372 | + expect(provisionResult.bucketName).toBe(bucketName); |
| 373 | + expect(provisionResult.accessType).toBe('private'); |
| 374 | + expect(provisionResult.versioning).toBe(true); |
| 375 | + expect(provisionResult.corsRules[0].allowedOrigins).toEqual(TEST_ORIGINS); |
| 376 | + |
| 377 | + // 2. Inspect — versioning gracefully skipped on MinIO, CORS not readable |
| 378 | + const inspected1 = await provisioner.inspect(bucketName, 'private'); |
| 379 | + expect(inspected1.bucketName).toBe(bucketName); |
| 380 | + // MinIO can't apply versioning or CORS |
| 381 | + expect(inspected1.versioning).toBe(false); |
| 382 | + expect(inspected1.corsRules).toHaveLength(0); |
| 383 | + |
| 384 | + // 3. Update CORS to new origins (graceful degradation on MinIO) |
| 385 | + const newOrigins = ['https://staging.example.com']; |
| 386 | + const updatedRules = await provisioner.updateCors({ |
| 387 | + bucketName, |
| 388 | + accessType: 'private', |
| 389 | + allowedOrigins: newOrigins, |
| 390 | + }); |
| 391 | + expect(updatedRules[0].allowedOrigins).toEqual(newOrigins); |
| 392 | + |
| 393 | + // 4. Re-inspect — bucket still exists and is accessible |
| 394 | + const inspected2 = await provisioner.inspect(bucketName, 'private'); |
| 395 | + expect(inspected2.bucketName).toBe(bucketName); |
| 396 | + }); |
| 397 | + }); |
| 398 | +}); |
0 commit comments