File size: 6,353 Bytes
0ccd578
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import {inspect} from 'node:util';
import {createServer,Server} from 'node:http';
import {AddressInfo} from 'node:net';
import {S3Client} from '@aws-sdk/client-s3';
import {UserContext} from '../src/core/context';
import {ObjectKey,ObjectPutOptions,ObjectReadUrlOptions,ObjectReadUrl,ObjectMetadata,ObjectContent,ObjectStorageProvider,StorageInvocation,StorageError,ObjectStorageEvent,MAX_OBJECT_PUT_SIZE} from '../src/core/object-storage';
import {S3ObjectStorage} from '../src/object-storage/s3';

class Stub implements ObjectStorageProvider {
  size=3;error?:StorageError;seen?:StorageInvocation;closes=0;
  async put(i:StorageInvocation,key:ObjectKey,_:AsyncIterable<Uint8Array>,o:ObjectPutOptions):Promise<ObjectMetadata>{this.seen=i;return new ObjectMetadata(key,o.size);}
  async head(i:StorageInvocation):Promise<undefined>{this.seen=i;if(this.error)throw this.error;return undefined;}
  async get(_:StorageInvocation,key:ObjectKey):Promise<ObjectContent>{return new ObjectContent(new ObjectMetadata(key,this.size),(async function*(){yield new Uint8Array([1,2,3]);})(),async()=>{this.closes++;});}
  async delete():Promise<void>{}
  async getReadUrl():Promise<ObjectReadUrl>{throw new StorageError('Unsupported');}
}
const key=new ObjectKey('PRIVATE-KEY-CANARY');
test('object storage validates options and masks private representations',()=>{
  for(const value of ['', 'x'.repeat(1025),'bad\nkey','\ud800'])expect(()=>new ObjectKey(value)).toThrow(StorageError);
  expect(()=>new ObjectPutOptions(MAX_OBJECT_PUT_SIZE+1,'create_only')).toThrow('TooLarge');
  expect(()=>new ObjectPutOptions(0,'create_only','bad\r\nheader')).toThrow('InvalidInput');
  expect(()=>new ObjectPutOptions(0,'create_only',undefined,{'Bad Key':'value'})).toThrow('InvalidInput');
  for(const lifetime of [0,-1,0.5,604801])expect(()=>new ObjectReadUrlOptions(lifetime)).toThrow('InvalidInput');
  expect(()=>new ObjectReadUrlOptions(1,'attachment','bad\nname')).toThrow('InvalidInput');
  const metadata={tag:'PRIVATE-METADATA-CANARY'},options=new ObjectPutOptions(0,'create_only',undefined,metadata);metadata.tag='changed';expect(options.metadata.tag).toBe('PRIVATE-METADATA-CANARY');expect(Object.isFrozen(options.metadata)).toBe(true);
  for(const value of [key,options,new ObjectReadUrl('PRIVATE-URL-CANARY',new Date()),new ObjectMetadata(key,0,'PRIVATE-TYPE-CANARY')])expect(inspect(value)).not.toContain('CANARY');
});
test('object storage retains context and propagates denied/unavailable errors',async()=>{
  expect(()=>new UserContext().objectStorage('read')).toThrow('NotConfigured');const provider=new Stub(),context=new UserContext().withObjectStorage(provider);
  expect(()=>context.objectStorage(' ')).toThrow('InvalidInput');const storage=context.objectStorage('read attachment');expect(await storage.exists(key)).toBe(false);expect(provider.seen?.context).toBe(context);expect(provider.seen?.comment).toBe('read attachment');
  for(const code of ['AccessDenied','Unavailable'] as const){provider.error=new StorageError(code);await expect(storage.exists(key)).rejects.toMatchObject({code});}
});
test('object storage stream ownership, limits, completion and observer safety',async()=>{
  const provider=new Stub(),events:ObjectStorageEvent[]=[],context=new UserContext().withObjectStorage(provider).withObjectStorageObserver(event=>events.push(event));const storage=context.objectStorage('read attachment');
  let closes=0;const input={ [Symbol.asyncIterator](){return {next:async()=>({done:false as const,value:new Uint8Array()}),return:async()=>{closes++;return {done:true as const,value:undefined};}};}};
  await storage.put(key,input,new ObjectPutOptions(0,'create_only'));expect(closes).toBe(1);
  const content=await storage.get(key);for await(const _ of content.stream)break;expect(events[events.length-1]?.outcome).toBe('cancelled');expect(provider.closes).toBe(1);
  expect(await storage.getBytes(key,3)).toEqual(new Uint8Array([1,2,3]));expect(events[events.length-1]?.outcome).toBe('success');
  await expect(storage.getBytes(key,2)).rejects.toMatchObject({code:'TooLarge'});
  for(const size of [2,4]){provider.size=size;await expect(storage.getBytes(key,5)).rejects.toMatchObject({code:'Io'});}
  expect(inspect(events)).not.toContain('CANARY');context.withObjectStorageObserver(()=>{throw new Error('observer failure');});expect(await storage.exists(key)).toBe(false);
});
test('S3 actual HTTP errors, malformed success and invalid streams do not become absence',async()=>{
  let status=403,calls=0;const server=createServer((request,response)=>{calls++;response.statusCode=request.url?.replace(/\/$/,'')==='/bucket'&&status===404?200:status;response.end();});
  await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));
  const endpoint=`http://127.0.0.1:${(server.address() as AddressInfo).port}`;
  const provider=new S3ObjectStorage(new S3Client({endpoint,region:'us-east-1',forcePathStyle:true,maxAttempts:1,credentials:{accessKeyId:'test',secretAccessKey:'secret'}}),'bucket');
  const storage=new UserContext().withObjectStorage(provider).withObjectStorageObserver(()=>{}).objectStorage('verify HTTP failures');
  try{
    for(const [code,expected] of [[403,'AccessDenied'],[500,'Unavailable'],[503,'Unavailable'],[408,'Timeout'],[504,'Timeout'],[501,'Unsupported']] as const){status=code;const error=await storage.exists(key).catch(error=>error);expect(error).toBeInstanceOf(StorageError);expect(error.code).toBe(expected);expect(error.cause).toBeUndefined();expect(error.message).not.toContain('CANARY');}
    status=404;expect(await storage.exists(key)).toBe(false);status=200;await expect(storage.exists(key)).rejects.toMatchObject({code:'Unavailable'});
    const before=calls;for(const [content,size] of [[new Uint8Array([1,2]),1],[new Uint8Array([1]),2]] as const)await expect(storage.putBytes(key,content,new ObjectPutOptions(size,'create_only'))).rejects.toMatchObject({code:'InvalidInput'});
    await expect(storage.put(key,(async function*(){throw new Error('PRIVATE-INPUT-CANARY');yield new Uint8Array();})(),new ObjectPutOptions(1,'create_only'))).rejects.toMatchObject({code:'Io'});
    expect(calls).toBe(before);await new Promise<void>(resolve=>server.close(()=>resolve()));await expect(storage.exists(key)).rejects.toMatchObject({code:'Unavailable'});
  }finally{server.close();provider.close();}
});