97 lines
8.1 KiB
JavaScript
97 lines
8.1 KiB
JavaScript
// 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.')
|
|
}
|