// 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 })