Files

142 lines
8.9 KiB
JavaScript

// Production component/RPC against actual disposable Immich. Synthetic login
// and installed binding only; no native/installed lifecycle or album claim.
const { chromium, expect } = require('/browser/node_modules/@playwright/test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const base = new URL(process.argv[2])
assert.equal(base.hostname, '127.0.0.1')
assert.equal(base.protocol, 'http:')
;(async () => {
const { qualifyImmich } = await import('/workspace/tests/cloud/immich-upstream-contract.mjs')
const fixture = await qualifyImmich({ fixturePath: '/workspace/tests/cloud/immich-fixture.png', deferRevocation: true })
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_EXECUTABLE,
headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
try {
const context = await browser.newContext({ acceptDownloads: true, serviceWorkers: 'block', viewport: { width: 390, height: 844 } })
await context.route('**/*', route => new URL(route.request().url()).origin === base.origin ? route.continue() : route.abort())
await context.routeWebSocket('**/*', socket => socket.close())
const page = await context.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.goto(base.href)
const library = page.getByLabel('Library', { exact: true })
const connect = async key => {
await page.getByRole('button', { name: 'Connect a library', exact: true }).click()
await page.getByLabel('App', { exact: true }).selectOption('immich')
await page.getByLabel('Immich API key', { exact: true }).fill(key)
await page.getByRole('button', { name: 'Connect', exact: true }).click()
await expect(page.getByLabel('Immich API key', { exact: true })).toHaveCount(0)
return library.inputValue()
}
const rpc = (method, params) => page.evaluate(async ({ method, params }) => {
const csrf = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)[1]
const r = await fetch('/rpc/v1', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) })
if (!r.ok) throw new Error(`RPC HTTP ${r.status}`)
return r.json()
}, { method, params })
const okRpc = async (method, params) => {
const body = await rpc(method, params)
assert.ok(!body.error, body.error?.message)
return body.result
}
const browse = connection_id => okRpc('cloud.sources.browse', { connection_id, page: 1 })
const alice = await connect(fixture.key)
await expect(library).toContainText(`Immich · ${fixture.account}`)
const button = page.getByRole('button', { name: 'Download fixture.png', exact: true })
await expect(button).toBeVisible()
const pending = page.waitForEvent('download')
await button.click()
const download = await pending
assert.equal(download.suggestedFilename(), 'fixture.png')
assert.deepEqual(await fs.readFile(await download.path()), fixture.source)
const item = (await browse(alice)).items.find(item => item.upstream_id === fixture.assetId)
assert.ok(item)
const prepare = (connection_id, download = true) => okRpc('cloud.sources.prepare-stream', {
connection_id, upstream_id: item.upstream_id, revision: item.revision, locator: item.locator, download,
})
const { url } = await prepare(alice)
const handle = new URL(url, base).href
const range = await context.request.get(handle, { headers: { Range: 'bytes=2-5' } })
assert.equal(range.status(), 206)
assert.equal(range.headers()['content-range'], `bytes 2-5/${fixture.source.length}`)
assert.deepEqual(await range.body(), fixture.source.subarray(2, 6))
const albumSelect = page.getByLabel('Immich album', { exact: true })
await expect(albumSelect.locator('option')).toHaveCount(3)
await albumSelect.selectOption(fixture.emptyAlbumId)
await expect(page.getByText('No files in this view.', { exact: true })).toBeVisible()
await expect(button).toHaveCount(0)
await albumSelect.selectOption(fixture.albumId)
await expect(button).toBeVisible()
await fixture.removeAlbum()
await page.getByRole('button', { name: 'Refresh', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('Other libraries remain available')
await expect(button).toHaveCount(0)
assert.equal(await albumSelect.inputValue(), fixture.albumId)
await albumSelect.selectOption('')
await expect(button).toBeVisible()
const staleAlbum = await rpc('cloud.sources.browse', { connection_id: alice, page: 1, album_id: fixture.albumId })
assert.ok(staleAlbum.error)
const bob = await connect(fixture.bobKey)
assert.notEqual(bob, alice)
await expect(albumSelect.locator('option')).toHaveCount(1)
const foreignAlbum = await rpc('cloud.sources.browse', { connection_id: bob, page: 1, album_id: fixture.emptyAlbumId })
assert.ok(foreignAlbum.error)
await expect(button).toHaveCount(0)
assert.ok(!(await browse(bob)).items.some(entry => entry.upstream_id === fixture.assetId))
const foreign = await prepare(bob)
assert.equal((await context.request.get(new URL(foreign.url, base).href)).status(), 409)
const limited = await connect(fixture.limitedKey)
assert.equal(limited, alice, 'Reconnecting the same verified account replaces credentials, not its library')
await expect(button).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Preview fixture.png', exact: true })).toBeVisible()
await expect(albumSelect).toHaveCount(0)
const deniedAlbum = await rpc('cloud.sources.browse', { connection_id: limited, page: 1, album_id: fixture.emptyAlbumId })
assert.ok(deniedAlbum.error)
const limitedItem = (await browse(limited)).items.find(entry => entry.upstream_id === fixture.assetId)
assert.equal(limitedItem.download, false)
assert.equal(limitedItem.preview, true)
const deniedOriginal = await prepare(limited)
assert.equal((await context.request.get(new URL(deniedOriginal.url, base).href)).status(), 409)
const preview = await prepare(limited, false)
const previewResponse = await context.request.get(new URL(preview.url, base).href)
assert.equal(previewResponse.status(), 200)
assert.match(previewResponse.headers()['content-type'], /^image\//)
assert.ok((await previewResponse.body()).length > 0)
await page.getByRole('button', { name: 'Preview fixture.png', exact: true }).click()
await expect(page.getByRole('dialog').getByRole('img', { name: 'fixture.png', exact: true })).toBeVisible()
await expect.poll(() => page.getByRole('dialog').locator('img').evaluate(img => img.complete && img.naturalWidth > 0)).toBe(true)
await page.reload()
await expect(library.locator('option')).toHaveCount(2)
await page.setViewportSize({ width: 1440, height: 900 })
assert.equal(await connect(fixture.key), alice)
await expect(button).toBeVisible()
const unauth = await browser.newContext()
assert.equal((await unauth.request.get(handle)).status(), 401)
await unauth.close()
assert.equal((await context.request.get(handle, { headers: { Origin: 'http://other.invalid' } })).status(), 403)
const badCsrf = await context.request.post(new URL('/rpc/v1', base).href, {
headers: { Origin: base.origin, 'X-CSRF-Token': 'invalid-synthetic' },
data: { jsonrpc: '2.0', id: 1, method: 'cloud.sources.disconnect', params: { connection_id: alice } },
})
assert.equal(badCsrf.status(), 403)
await fixture.revoke()
assert.ok((await rpc('cloud.sources.browse', { connection_id: alice, page: 1 })).error)
assert.equal((await context.request.get(handle)).status(), 409)
await library.selectOption(bob)
await expect(page.getByText('No files in this view.', { exact: true })).toBeVisible()
assert.ok((await browse(bob)).items)
assert.equal(await connect(fixture.limitedKey), alice)
await expect(page.getByRole('button', { name: 'Preview fixture.png', exact: true })).toBeVisible()
const restoredPreview = await prepare(limited, false)
assert.equal((await context.request.get(new URL(restoredPreview.url, base).href)).status(), 200)
await page.getByRole('button', { name: 'Disconnect', exact: true }).click()
await page.getByRole('dialog').getByRole('button', { name: 'Disconnect', exact: true }).click()
await expect(library.locator('option')).toHaveCount(1)
assert.equal((await context.request.get(new URL(restoredPreview.url, base).href)).status(), 409)
assert.deepEqual(errors, [])
console.log('PASS production Cloud UI→RPC→Immich account isolation, original/range/preview, permission denial, private/empty album selection and removal without asset loss, reload, session/origin/CSRF, upstream and local revocation; no File Browser')
await context.close()
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })