storage.test.js (16120B)
1 // test/storage.test.js - object storage backends 2 // 3 // The local backend is always exercised. The S3 backend jobs against a real 4 // MinIO, started automatically when docker is available, because the 5 // signing code is written by hand here and a stub would happily accept a 6 // signature a real server rejects. 7 // 8 // Point the same tests at another implementation, such as Garage or AWS, 9 // with CONDUCTOR_TEST_S3_ENDPOINT, CONDUCTOR_TEST_S3_KEY and 10 // CONDUCTOR_TEST_S3_SECRET. 11 12 import test, { before, after } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 import fs from 'node:fs/promises'; 15 import os from 'node:os'; 16 import path from 'node:path'; 17 import { Readable } from 'node:stream'; 18 import crypto from 'node:crypto'; 19 import { createStorage, keys, StorageNotFound, assertKey, sanitizeRelativePath } from '../src/lib/storage/index.js'; 20 import { sha256Hex, signRequest } from '../src/lib/storage/sigv4.js'; 21 import { partSizeFor, inParts } from '../src/lib/storage/s3.js'; 22 import { startMinio, s3Available } from './helpers/minio.js'; 23 24 async function drain(stream) { 25 let out = ''; 26 for await (const chunk of stream) out += chunk; 27 return out; 28 } 29 30 async function localStorage() { 31 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-store-')); 32 const cfg = { storage: { driver: 'local', path: dir, s3: {} } }; 33 return { st: createStorage(cfg), cleanup: () => fs.rm(dir, { recursive: true, force: true }) }; 34 } 35 36 test('key validation rejects traversal and absolute paths', () => { 37 for (const bad of ['../etc/passwd', '/abs', 'a//b', 'a/../b', 'a/./b', '', 'a\\b', 'a\0b']) { 38 assert.throws(() => assertKey(bad), new RegExp('storage key'), `expected ${JSON.stringify(bad)} to be rejected`); 39 } 40 assert.equal(assertKey('artifacts/job/task/dist/app.tar.gz'), 'artifacts/job/task/dist/app.tar.gz'); 41 }); 42 43 test('sanitizeRelativePath strips traversal from worker supplied paths', () => { 44 assert.equal(sanitizeRelativePath('../../etc/passwd'), 'etc/passwd'); 45 assert.equal(sanitizeRelativePath('./dist/./app.bin'), 'dist/app.bin'); 46 assert.equal(sanitizeRelativePath('/abs/path'), 'abs/path'); 47 assert.equal(sanitizeRelativePath('..'), null); 48 assert.equal(sanitizeRelativePath(''), null); 49 }); 50 51 test('key layout is stable', () => { 52 assert.equal(keys.artifact('r1', 'r1:build', 'dist/a.bin'), 'artifacts/r1/r1:build/dist/a.bin'); 53 assert.equal(keys.log('r1', 'r1:build'), 'logs/r1/r1:build.log'); 54 }); 55 56 test('local backend round trips a buffer and reports its digest', async () => { 57 const { st, cleanup } = await localStorage(); 58 try { 59 const put = await st.put('a/b/c.txt', Buffer.from('hello conductor')); 60 assert.equal(put.size, 15); 61 assert.equal(put.sha256, sha256Hex(Buffer.from('hello conductor'))); 62 63 assert.equal(await drain((await st.get('a/b/c.txt')).stream), 'hello conductor'); 64 assert.equal((await st.head('a/b/c.txt')).size, 15); 65 } finally { 66 await cleanup(); 67 } 68 }); 69 70 test('local backend serves ranges', async () => { 71 const { st, cleanup } = await localStorage(); 72 try { 73 await st.put('r.txt', Buffer.from('hello conductor')); 74 assert.equal(await drain((await st.get('r.txt', { range: { start: 6, end: 14 } })).stream), 'conductor'); 75 assert.equal(await drain((await st.get('r.txt', { range: { start: 6 } })).stream), 'conductor'); 76 } finally { 77 await cleanup(); 78 } 79 }); 80 81 test('local backend accepts a stream', async () => { 82 const { st, cleanup } = await localStorage(); 83 try { 84 const put = await st.put('s.log', Readable.from(['line one\n', 'line two\n'])); 85 assert.equal(put.size, 18); 86 assert.equal(await drain((await st.get('s.log')).stream), 'line one\nline two\n'); 87 } finally { 88 await cleanup(); 89 } 90 }); 91 92 test('local backend reports a missing object and deletes idempotently', async () => { 93 const { st, cleanup } = await localStorage(); 94 try { 95 await assert.rejects(st.get('nope'), (e) => e instanceof StorageNotFound); 96 assert.equal(await st.head('nope'), null); 97 await st.delete('nope'); 98 } finally { 99 await cleanup(); 100 } 101 }); 102 103 test('local backend has no presigned urls', async () => { 104 const { st, cleanup } = await localStorage(); 105 try { 106 assert.equal(await st.presign('a.txt'), null); 107 } finally { 108 await cleanup(); 109 } 110 }); 111 112 test('a partial write leaves no object behind', async () => { 113 const { st, cleanup } = await localStorage(); 114 try { 115 const failing = new Readable({ 116 read() { this.destroy(new Error('source failed')); }, 117 }); 118 await assert.rejects(st.put('partial.bin', failing), /source failed/); 119 assert.equal(await st.head('partial.bin'), null); 120 } finally { 121 await cleanup(); 122 } 123 }); 124 125 // --- multipart mechanics, no server needed --- 126 127 test('part size grows so that a huge object still fits in 10000 parts', () => { 128 const MiB = 1024 * 1024; 129 130 // Small and unknown sizes use the default. 131 assert.equal(partSizeFor(undefined), 16 * MiB); 132 assert.equal(partSizeFor(0), 16 * MiB); 133 assert.equal(partSizeFor(100 * MiB), 16 * MiB); 134 135 // 10000 parts of 16 MiB covers about 156 GiB; past that the part size 136 // has to grow or the upload cannot be expressed. 137 const huge = 1024 * 1024 * MiB; // 1 TiB 138 const size = partSizeFor(huge); 139 assert.ok(size > 16 * MiB, 'part size should scale up'); 140 assert.ok(Math.ceil(huge / size) <= 10000, 'must fit within the part limit'); 141 assert.equal(size % MiB, 0, 'part size should stay a whole number of MiB'); 142 }); 143 144 test('inParts regroups an awkwardly chunked stream into fixed parts', async () => { 145 const source = Readable.from([ 146 Buffer.from('aaa'), Buffer.from('bb'), Buffer.from('cccccc'), Buffer.from('d'), 147 ]); 148 149 const parts = []; 150 for await (const part of inParts(source, 4)) parts.push(part.toString()); 151 152 // Everything is preserved, in order, in fixed sized pieces with a 153 // remainder at the end. 154 assert.deepEqual(parts, ['aaab', 'bccc', 'cccd']); 155 assert.equal(parts.join(''), 'aaabbccccccd'); 156 }); 157 158 test('inParts handles a chunk far larger than the part size', async () => { 159 const source = Readable.from([Buffer.alloc(10, 0x41)]); 160 const parts = []; 161 for await (const part of inParts(source, 4)) parts.push(part.length); 162 assert.deepEqual(parts, [4, 4, 2]); 163 }); 164 165 test('inParts yields nothing for an empty stream', async () => { 166 const parts = []; 167 for await (const part of inParts(Readable.from([]), 4)) parts.push(part); 168 assert.deepEqual(parts, []); 169 }); 170 171 // S3 coverage against a real server. 172 const s3Options = { skip: s3Available() ? false : 'docker is not available' }; 173 174 let minio = null; 175 176 before(async () => { 177 if (s3Available()) minio = await startMinio(); 178 }, { timeout: 240000 }); 179 180 after(async () => { 181 if (minio) await minio.stop(); 182 }); 183 184 // A storage handle onto a fresh bucket. 185 async function s3Storage() { 186 const bucket = await minio.bucket(); 187 return createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } }); 188 } 189 190 test('s3 backend round trips awkward keys against a real server', s3Options, async () => { 191 const st = await s3Storage(); 192 193 // Spaces, plus signs and parentheses are exactly what a naive signer 194 // gets wrong, and what a stubbed server would never catch. 195 const key = 'artifacts/r1/r1:build/dist/app bin+v1(final)!.tar.gz'; 196 const put = await st.put(key, Buffer.from('hello from s3'), { contentType: 'application/gzip' }); 197 assert.equal(put.size, 13); 198 assert.equal(put.sha256, sha256Hex(Buffer.from('hello from s3'))); 199 200 assert.equal(await drain((await st.get(key)).stream), 'hello from s3'); 201 assert.equal((await st.head(key)).size, 13); 202 }); 203 204 test('s3 serves byte ranges', s3Options, async () => { 205 const st = await s3Storage(); 206 await st.put('r.txt', Buffer.from('hello conductor')); 207 208 assert.equal(await drain((await st.get('r.txt', { range: { start: 6, end: 14 } })).stream), 'conductor'); 209 assert.equal(await drain((await st.get('r.txt', { range: { start: 6 } })).stream), 'conductor'); 210 }); 211 212 test('s3 accepts a stream when the size is known', s3Options, async () => { 213 const st = await s3Storage(); 214 const body = Readable.from([Buffer.from('streamed '), Buffer.from('upload')]); 215 const put = await st.put('logs/r1/j.log', body, { size: 15 }); 216 217 assert.equal(put.size, 15); 218 assert.equal(await drain((await st.get('logs/r1/j.log')).stream), 'streamed upload'); 219 }); 220 221 test('a presigned url is usable without credentials', s3Options, async () => { 222 const st = await s3Storage(); 223 const key = 'artifacts/job/task/dist/app bin+v1.tar.gz'; 224 await st.put(key, Buffer.from('presigned content')); 225 226 const url = await st.presign(key, { expires: 300 }); 227 assert.match(url, /X-Amz-Signature=[0-9a-f]{64}/); 228 229 // Plain fetch, no Authorization header. 230 const res = await fetch(url); 231 assert.equal(res.status, 200); 232 assert.equal(await res.text(), 'presigned content'); 233 }); 234 235 test('a tampered presigned url is rejected by the server', s3Options, async () => { 236 const st = await s3Storage(); 237 await st.put('secret.txt', Buffer.from('do not serve this')); 238 239 const url = new URL(await st.presign('secret.txt', { expires: 300 })); 240 const signature = url.searchParams.get('X-Amz-Signature'); 241 // Flip a character of the signature. 242 url.searchParams.set('X-Amz-Signature', `${signature.slice(0, -1)}${signature.endsWith('a') ? 'b' : 'a'}`); 243 244 const res = await fetch(url); 245 assert.ok(!res.ok, 'a forged signature must not be honoured'); 246 }); 247 248 test('s3 reports a missing object and deletes idempotently', s3Options, async () => { 249 const st = await s3Storage(); 250 await assert.rejects(st.get('missing/object'), (e) => e instanceof StorageNotFound); 251 assert.equal(await st.head('missing/object'), null); 252 253 await st.put('gone.txt', Buffer.from('x')); 254 await st.delete('gone.txt'); 255 assert.equal(await st.head('gone.txt'), null); 256 await st.delete('gone.txt'); 257 }); 258 259 test('a stream of unknown length is accepted', s3Options, async () => { 260 const st = await s3Storage(); 261 262 // Multipart does not need the total up front, so a body whose length is 263 // not known in advance no longer has to be buffered by the caller. 264 const put = await st.put('unknown.bin', Readable.from([ 265 Buffer.from('one '), Buffer.from('two '), Buffer.from('three'), 266 ])); 267 268 assert.equal(put.size, 13); 269 assert.equal(await drain((await st.get('unknown.bin')).stream), 'one two three'); 270 }); 271 272 test('a body past the single request threshold is uploaded in parts', s3Options, async () => { 273 const st = await s3Storage(); 274 275 // Above SINGLE_PUT_MAX, so this exercises create, several uploads and 276 // complete rather than one PUT. 277 const size = 40 * 1024 * 1024; 278 const pattern = Buffer.alloc(1024 * 1024); 279 for (let i = 0; i < pattern.length; i += 4) pattern.writeUInt32BE(i, i); 280 281 const expected = crypto.createHash('sha256'); 282 let produced = 0; 283 const source = new Readable({ 284 read() { 285 if (produced >= size) return this.push(null); 286 // Vary each block so a part written out of order would be visible. 287 const block = Buffer.from(pattern); 288 block.writeUInt32BE(produced / 1024 / 1024, 0); 289 produced += block.length; 290 expected.update(block); 291 this.push(block); 292 }, 293 }); 294 295 const put = await st.put('big/multipart.bin', source, { size }); 296 assert.equal(put.size, size); 297 assert.equal(put.sha256, expected.digest('hex'), 'the assembled object must match what was sent'); 298 299 // Read it back independently rather than trusting the reported digest. 300 const back = crypto.createHash('sha256'); 301 let read = 0; 302 for await (const chunk of (await st.get('big/multipart.bin')).stream) { 303 back.update(chunk); 304 read += chunk.length; 305 } 306 assert.equal(read, size); 307 assert.equal(back.digest('hex'), put.sha256); 308 }); 309 310 test('a large body really is sent as several parts, and a small one is not', s3Options, async () => { 311 const bucket = await minio.bucket(); 312 const st = createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } }); 313 314 // A multipart ETag is the digest of the part digests with the part 315 // count appended, so it says plainly how the object was uploaded. That 316 // makes it a reliable check that streaming is still in use, rather than 317 // inferring it from timing or memory. 318 async function etagOf(key) { 319 const url = new URL(`${minio.endpoint}/${bucket}/${key}`); 320 const headers = signRequest({ 321 method: 'HEAD', 322 url, 323 payloadHash: sha256Hex(''), 324 region: minio.credentials.region, 325 accessKeyId: minio.credentials.accessKeyId, 326 secretAccessKey: minio.credentials.secretAccessKey, 327 }); 328 const res = await fetch(url, { method: 'HEAD', headers }); 329 assert.ok(res.ok, `HEAD ${key} failed: ${res.status}`); 330 return res.headers.get('etag').replace(/"/g, ''); 331 } 332 333 // 40 MiB at a 16 MiB part size is three parts. 334 const size = 40 * 1024 * 1024; 335 const source = Readable.from((function* () { 336 for (let sent = 0; sent < size; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x43); 337 })()); 338 await st.put('big/parted.bin', source, { size }); 339 340 const multipart = await etagOf('big/parted.bin'); 341 assert.match(multipart, /-3$/, `expected a three part upload, got ETag ${multipart}`); 342 343 // Something small must not pay for three round trips. 344 await st.put('small/plain.bin', Buffer.alloc(1024, 0x44)); 345 const single = await etagOf('small/plain.bin'); 346 assert.ok(!/-\d+$/.test(single), `expected a single request upload, got ETag ${single}`); 347 }); 348 349 test('a multipart object still serves ranges', s3Options, async () => { 350 const st = await s3Storage(); 351 const size = 40 * 1024 * 1024; 352 353 const source = Readable.from((function* () { 354 for (let sent = 0; sent < size; sent += 1024 * 1024) { 355 const block = Buffer.alloc(1024 * 1024); 356 block.writeUInt32BE(sent, 0); 357 yield block; 358 } 359 })()); 360 361 await st.put('big/ranged.bin', source, { size }); 362 363 // A boundary between parts is the interesting place to read across. 364 const start = 16 * 1024 * 1024 - 2; 365 const chunk = await st.get('big/ranged.bin', { range: { start, end: start + 5 } }); 366 const bytes = []; 367 for await (const part of chunk.stream) bytes.push(part); 368 assert.equal(Buffer.concat(bytes).length, 6); 369 }); 370 371 test('a body that does not match its declared size is rejected', s3Options, async () => { 372 const st = await s3Storage(); 373 const size = 40 * 1024 * 1024; 374 375 // Claims 40 MiB, sends 20 MiB. 376 const short = Readable.from((function* () { 377 for (let sent = 0; sent < size / 2; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x41); 378 })()); 379 380 await assert.rejects(st.put('big/short.bin', short, { size }), /size mismatch/); 381 assert.equal(await st.head('big/short.bin'), null, 'no partial object should be visible'); 382 }); 383 384 test('a failed multipart upload does not leave parts behind', s3Options, async () => { 385 const bucket = await minio.bucket(); 386 const st = createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } }); 387 const size = 40 * 1024 * 1024; 388 389 const short = Readable.from((function* () { 390 for (let sent = 0; sent < size / 2; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x42); 391 })()); 392 await assert.rejects(st.put('big/abandoned.bin', short, { size }), /size mismatch/); 393 394 // Abandoned parts are billable and invisible, so the upload must have 395 // been aborted rather than simply dropped. 396 const url = new URL(`${minio.endpoint}/${bucket}`); 397 url.searchParams.set('uploads', ''); 398 const headers = signRequest({ 399 method: 'GET', 400 url, 401 payloadHash: sha256Hex(''), 402 region: minio.credentials.region, 403 accessKeyId: minio.credentials.accessKeyId, 404 secretAccessKey: minio.credentials.secretAccessKey, 405 }); 406 407 const listing = await (await fetch(url, { headers })).text(); 408 assert.ok(!listing.includes('<Upload>'), `an upload was left pending:\n${listing}`); 409 }); 410 411 test('bad credentials fail loudly rather than silently storing nothing', s3Options, async () => { 412 const bucket = await minio.bucket(); 413 const st = createStorage({ 414 storage: { 415 driver: 's3', 416 s3: { ...minio.storageConfig(bucket), secret_access_key: 'wrong-secret' }, 417 }, 418 }); 419 420 await assert.rejects(st.put('k.txt', Buffer.from('x')), /s3 put failed/); 421 });