// Disposable actual Immich API contract probe; not paired Rust/browser acceptance. import assert from 'node:assert/strict' const base = 'http://127.0.0.1:2283/api' async function call(path, { method='GET', token, key, body, headers={} }={}) { if (token) headers.Authorization = `Bearer ${token}` if (key) headers['x-api-key'] = key if (body && !(body instanceof FormData)) { headers['Content-Type']='application/json'; body=JSON.stringify(body) } return fetch(base+path,{method,headers,body,redirect:'manual',signal:AbortSignal.timeout(15000)}) } async function json(path, options={}) { const response=await call(path,options) assert.ok(response.ok, `${options.method??'GET'} ${path}: HTTP ${response.status}`) return response.json() } function pass(name) { console.log(`PASS ${name}`) } // The paired browser may reuse this setup in the same network-none namespace. // Deferred revocation returns synthetic credentials in memory, never stdout. export async function qualifyImmich({ fixturePath='/fixture.png', deferRevocation=false }={}) { const version=await json('/server/version'); assert.deepEqual(version,{major:2,minor:7,patch:4}); pass('pinned Immich2.7.4 API') const alice={email:'alice@example.test',password:'Synthetic-Immich-A-Only-2026!',name:'Fixture Alice'} await json('/auth/admin-sign-up',{method:'POST',body:alice}) const login=await json('/auth/login',{method:'POST',body:{email:alice.email,password:alice.password}}) const token=login.accessToken; assert.ok(token) const bob=await json('/admin/users',{method:'POST',token,body:{email:'bob@example.test',password:'Synthetic-Immich-B-Only-2026!',name:'Fixture Bob',shouldChangePassword:false}}) const bobLogin=await json('/auth/login',{method:'POST',body:{email:'bob@example.test',password:'Synthetic-Immich-B-Only-2026!'}}) const issued=await json('/api-keys',{method:'POST',token,body:{name:'fixture-full',permissions:['all']}}) const key=issued.secret; assert.ok(key); const keyId=issued.apiKey.id const identity=await json('/users/me',{key}); assert.equal(identity.id,login.userId) const info=await json('/api-keys/me',{key}); assert.ok(info.permissions.includes('all')); pass('API key derives account and permissions') const source=Buffer.from(await (await import('node:fs/promises')).readFile(fixturePath)) const form=new FormData() for (const [name,value] of Object.entries({deviceAssetId:'fixture-png',deviceId:'isolated-contract',fileCreatedAt:'2026-10-21T00:00:00.000Z',fileModifiedAt:'2026-10-21T00:00:00.000Z'})) form.append(name,value) form.append('assetData',new Blob([source],{type:'image/png'}),'fixture.png') const upload=await json('/assets',{method:'POST',key,body:form}); assert.ok(upload.id); pass('disposable API asset upload') const listed=await json('/search/metadata',{method:'POST',key,body:{page:1,size:100,withDeleted:false,withExif:true}}) assert.ok(listed.assets.items.some(item=>item.id===upload.id)); assert.equal(listed.assets.nextPage,null) const item=await json(`/assets/${upload.id}`,{key}); assert.equal(item.ownerId,identity.id); assert.ok(item.checksum); assert.ok(item.updatedAt); pass('metadata listing and stable asset identity') const other=await json('/search/metadata',{method:'POST',token:bobLogin.accessToken,body:{page:1,size:100,withDeleted:false,withExif:true}}) assert.ok(!other.assets.items.some(item=>item.id===upload.id)) const foreign=await call(`/assets/${upload.id}/original`,{token:bobLogin.accessToken}); assert.ok([400,403,404].includes(foreign.status)); pass('separate account cannot list/download private asset') const limited=await json('/api-keys',{method:'POST',token,body:{name:'fixture-preview-only',permissions:['user.read','apiKey.read','asset.read','asset.view']}}) const denied=await call(`/assets/${upload.id}/original`,{key:limited.secret}); assert.ok([400,401,403].includes(denied.status)); pass('view permission does not grant original download') const album=await json('/albums',{method:'POST',key,body:{albumName:'Private fixture album',assetIds:[upload.id]}}) const emptyAlbum=await json('/albums',{method:'POST',key,body:{albumName:'Empty fixture album'}}) const albums=await json('/albums',{key}); assert.ok(albums.some(item=>item.id===album.id)); assert.ok(albums.some(item=>item.id===emptyAlbum.id)) const albumItems=await json('/search/metadata',{method:'POST',key,body:{page:1,size:100,withDeleted:false,withExif:true,albumIds:[album.id]}}) assert.ok(albumItems.assets.items.some(item=>item.id===upload.id)) const emptyItems=await json('/search/metadata',{method:'POST',key,body:{page:1,size:100,withDeleted:false,withExif:true,albumIds:[emptyAlbum.id]}}) assert.equal(emptyItems.assets.items.length,0) const bobAlbums=await json('/albums',{token:bobLogin.accessToken}); assert.ok(!bobAlbums.some(item=>item.id===album.id || item.id===emptyAlbum.id)) assert.ok([400,403,404].includes((await call(`/albums/${album.id}`,{token:bobLogin.accessToken})).status)) assert.ok([400,401,403].includes((await call('/albums',{key:limited.secret})).status)) pass('actual private album inventory/filter, empty album and account/permission refusals') const removeAlbum = async () => { assert.ok((await call(`/albums/${album.id}`,{method:'DELETE',key})).ok) assert.ok(!(await json('/albums',{key})).some(item=>item.id===album.id)) assert.equal((await json(`/assets/${upload.id}`,{key})).id,upload.id) pass('album removal preserves its original asset') } const originalPath=`/assets/${upload.id}/original?edited=false` const head=await call(originalPath,{method:'HEAD',key,headers:{'Accept-Encoding':'identity'}}) assert.equal(head.status,200); assert.equal(Number(head.headers.get('content-length')),source.length) const etag=head.headers.get('etag'); assert.ok(etag) const conditional=etag.startsWith('W/')?{'If-Unmodified-Since':head.headers.get('last-modified')}:{'If-Match':etag} const bytes=await call(originalPath,{key,headers:{...conditional,'Accept-Encoding':'identity'}}) assert.equal(bytes.status,200); assert.deepEqual(Buffer.from(await bytes.arrayBuffer()),source); pass('authenticated exact original bytes') const range=await call(originalPath,{key,headers:{...conditional,Range:'bytes=2-5','Accept-Encoding':'identity'}}) assert.equal(range.status,206); assert.equal(range.headers.get('content-range'),`bytes 2-5/${source.length}`); assert.deepEqual(Buffer.from(await range.arrayBuffer()),source.subarray(2,6)); pass('conditional exact original range') let preview for(let attempt=0;attempt<45;attempt++) { preview=await call(`/assets/${upload.id}/thumbnail?size=preview&edited=false`,{method:'HEAD',key:limited.secret}) if(preview.status===200) break await new Promise(resolve=>setTimeout(resolve,1000)) } assert.equal(preview.status,200); assert.ok(/^image\//.test(preview.headers.get('content-type')??'')); assert.ok(preview.headers.get('etag')); pass('actual derived preview with view-only key') const revoke = async () => { const removal=await call(`/api-keys/${keyId}`,{method:'DELETE',token}); assert.ok(removal.ok) const revoked=await call('/users/me',{key}); assert.equal(revoked.status,401) assert.equal((await json('/users/me',{token:bobLogin.accessToken})).id,bob.id); pass('revoked key denied while other account survives') } if (deferRevocation) { const bobKey=await json('/api-keys',{method:'POST',token:bobLogin.accessToken,body:{name:'fixture-bob',permissions:['all']}}) return { key, limitedKey: limited.secret, bobKey: bobKey.secret, account: identity.id, otherAccount: bob.id, assetId: upload.id, albumId: album.id, emptyAlbumId: emptyAlbum.id, source, revoke, removeAlbum } } await revoke() console.log('No File Browser, host port, external network or real account used; no Rust/browser integration acceptance claimed.') } if (process.argv[1] && import.meta.url === (await import('node:url')).pathToFileURL(process.argv[1]).href) { const fixture = await qualifyImmich({ deferRevocation: true }) assert.equal((await json('/users/me',{key:fixture.bobKey})).id, fixture.otherAccount) assert.equal((await json('/users/me',{key:fixture.limitedKey})).id, fixture.account) await fixture.removeAlbum() await fixture.revoke() console.log('No File Browser, host port, external network or real account used; no Rust/browser integration acceptance claimed.') }