// The /account/api/drive endpoints: session-gated, served by the drive // extension over fake storage, write path blocked in read-only mode. import { PersonalDataServer } from '@pdsjs/core'; import { CID, cborEncodeDagCbor, cidToString, createBlobCid, createCid, } from '@pdsjs/core/repo'; import { describe, expect, it } from 'vitest'; import { createDriveAccountRoutes } from '../src/account.js'; import { createDriveBrowser } from '../src/browser.js'; import { createDriveWriter } from '../src/writer.js'; const DID = 'did:plc:driveapitest'; const PASSWORD = 'account-api-password'; const PDS_HOST = 'pds.example.com'; const NOTES = new TextEncoder().encode('the notes'); const SHOT = new TextEncoder().encode('jpeg bytes here'); /** @param {{name: string, parent?: string}} folder */ function folderRecord(folder) { return cborEncodeDagCbor({ $type: 'dev.pdsjs.drive.folder', name: folder.name, ...(folder.parent ? { parent: folder.parent } : {}), createdAt: '2026-08-14T00:00:00.000Z', }); } /** * @param {{name: string, folder?: string, mimeType: string, data: Uint8Array}} file */ async function fileRecord(file) { return cborEncodeDagCbor({ $type: 'dev.pdsjs.drive.file', name: file.name, ...(file.folder ? { folder: file.folder } : {}), parts: [ { $type: 'blob', ref: new CID(await createBlobCid(file.data)), mimeType: 'application/octet-stream', size: file.data.length, }, ], mimeType: file.mimeType, createdAt: '2026-08-14T00:00:00.000Z', }); } /** * @param {{drive?: boolean, readOnly?: boolean, upload?: boolean, streaming?: boolean, spaces?: Array<{uri: string, spaceType: string, deletedAt?: string}>}} [opts] */ async function makePds(opts = {}) { /** @type {Map} */ const records = new Map(); // One folder record, so a file can name it by key the way the lexicon says. for (const folder of [{ rkey: '3photostid', name: 'photos' }]) { const value = folderRecord(folder); const uri = `at://${DID}/dev.pdsjs.drive.folder/${folder.rkey}`; records.set(uri, { uri, cid: cidToString(await createCid(value)), value }); } const specs = [ { rkey: '3notestid', name: 'notes.txt', mimeType: 'text/plain', data: NOTES, }, { rkey: '3shottid', name: 'shot.jpg', folder: '3photostid', mimeType: 'image/jpeg', data: SHOT, }, ]; for (const spec of specs) { const value = await fileRecord(spec); const uri = `at://${DID}/dev.pdsjs.drive.file/${spec.rkey}`; records.set(uri, { uri, cid: cidToString(await createCid(value)), value }); } const deleted = /** @type {string[]} */ ([]); const unlinked = /** @type {string[]} */ ([]); const created = /** @type {Array<{type: string, skey: string}>} */ ([]); // The repo event log, which the change feed reads to build a delta. /** @type {Array<{seq: number, evt: Uint8Array}>} */ const events = []; const actorStorage = /** @type {any} */ ({ getDid: async () => DID, getHandle: async () => 'user.example.com', getAccountStatus: async () => 'active', getRecord: async (/** @type {string} */ uri) => records.get(uri) || null, listRecords: async (/** @type {string} */ collection) => ({ records: [...records.values()] .filter((row) => row.uri.includes(`/${collection}/`)) .map((row) => ({ ...row })), cursor: null, }), getLatestSeq: async () => events.length, getEventsBefore: async ( /** @type {number} */ before, /** @type {number} */ limit, ) => events .filter((row) => row.seq < before) .sort((left, right) => right.seq - left.seq) .slice(0, limit), deleteRecord: async (/** @type {string} */ uri) => { deleted.push(uri); records.delete(uri); }, unlinkBlobsFromRecord: async (/** @type {string} */ uri) => { unlinked.push(uri); }, putBlock: async () => {}, putRecord: async ( /** @type {string} */ uri, /** @type {string} */ cid, /** @type {string} */ _collection, /** @type {string} */ _rkey, /** @type {Uint8Array} */ value, ) => { records.set(uri, { uri, cid, value }); }, putBlob: async () => {}, linkBlobToRecord: async () => {}, }); /** @type {Map} */ const written = new Map(); const blobs = /** @type {any} */ ({ put: async ( /** @type {string} */ _did, /** @type {string} */ cid, /** @type {Uint8Array} */ data, ) => { written.set(cid, data); }, get: async (/** @type {string} */ _did, /** @type {string} */ cid) => { for (const data of [NOTES, SHOT]) { if (cidToString(await createBlobCid(data)) === cid) { return { data, mimeType: 'application/octet-stream' }; } } const stored = written.get(cid); return stored ? { data: stored, mimeType: 'application/octet-stream' } : null; }, ...(opts.streaming ? { createWriter: async () => { /** @type {Uint8Array[]} */ const staged = []; return { write: async (/** @type {Uint8Array} */ chunk) => { staged.push(chunk); }, commit: async (/** @type {string} */ cid) => { const size = staged.reduce((sum, c) => sum + c.length, 0); const data = new Uint8Array(size); let at = 0; for (const c of staged) { data.set(c, at); at += c.length; } written.set(cid, data); }, abort: async () => {}, }; }, } : {}), }); const pds = new PersonalDataServer({ actorStorage, sharedStorage: /** @type {any} */ ({}), blobs, jwtSecret: 'account-api-secret', hostname: PDS_HOST, password: PASSWORD, readOnly: opts.readOnly, blobUploadLimit: 64, extensions: opts.drive === false ? [] : [ (host) => { const ports = { actorStorage, blobs, getDid: host.getDid }; const browser = createDriveBrowser(ports); const writer = opts.upload === false ? null : createDriveWriter({ ...ports, createCommit: host.createCommit, chunkSize: host.blobUploadLimit, }); return { name: 'drive', collections: ['dev.pdsjs.drive.file', 'dev.pdsjs.drive.folder'], routes: createDriveAccountRoutes({ host, browser, writer, spaceBrowser: opts.spaces ? /** @type {any} */ ({ listSpaces: async () => opts.spaces?.map((space) => ({ deletedAt: null, ...space, })), countRecords: async () => [], }) : null, spaceAdmin: opts.spaces ? /** @type {any} */ ({ createSpace: async ( /** @type {{type: string, skey: string}} */ made, ) => { created.push(made); return { uri: `at://${DID}/space/${made.type}/${made.skey}`, }; }, }) : null, }), features: { drive: true, driveUpload: Boolean(writer) }, }; }, ], }); // Commit and reap run their full course in integration; here the API // contract is under test, not the repo machinery. const commits = /** @type {Array<{action: string, uri: string}[]>} */ ([]); pds.createCommit = /** @type {any} */ ( async (/** @type {string} */ _did, /** @type {any[]} */ writes) => { commits.push(writes); // The real commit appends to the event log; the change feed reads it. events.push({ seq: events.length + 1, evt: cborEncodeDagCbor({ ops: writes.map((write) => ({ action: write.action, path: write.uri.split('/').slice(-2).join('/'), })), }), }); } ); let reaped = 0; pds.reapOrphanedBlobs = /** @type {any} */ ( async () => { reaped++; return []; } ); return { pds, deleted, unlinked, commits, records, created, reapCount: () => reaped, }; } /** @param {PersonalDataServer} pds */ async function signInCookie(pds) { const response = await pds.fetch( new Request(`https://${PDS_HOST}/account/sign-in`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: DID, password: PASSWORD, }).toString(), }), ); const header = response.headers.get('set-cookie') || ''; const value = decodeURIComponent( header.match(/pdsjs_account=([^;]*)/)?.[1] ?? '', ); expect(value).not.toBe(''); return `pdsjs_account=${encodeURIComponent(value)}`; } /** * @param {PersonalDataServer} pds * @param {string} path * @param {{cookie?: string, method?: string, body?: unknown}} [opts] */ async function apiRaw(pds, path, opts = {}) { return pds.fetch( new Request(`https://${PDS_HOST}${path}`, { method: opts.method || 'GET', headers: { ...(opts.cookie ? { Cookie: opts.cookie } : {}), ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }), ); } /** * @param {PersonalDataServer} pds * @param {string} path * @param {{cookie?: string, method?: string, body?: unknown}} [opts] */ async function api(pds, path, opts = {}) { const response = await apiRaw(pds, path, opts); return { status: response.status, body: await response.json() }; } describe('/account/api/drive/files', () => { it('requires a session', async () => { const { pds } = await makePds(); const { status } = await api(pds, '/account/api/drive/files'); expect(status).toBe(401); }); it('lists the top level through the browser port', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api(pds, '/account/api/drive/files', { cookie, }); expect(status).toBe(200); expect(body.enabled).toBe(true); expect(body.folder).toBe(''); expect(body.files.map((/** @type {any} */ f) => f.name)).toEqual([ 'notes.txt', ]); expect(body.folders).toEqual([ { rkey: '3photostid', name: 'photos', files: 1, size: SHOT.length, createdAt: '2026-08-14T00:00:00.000Z', }, ]); expect(body.totalFiles).toBe(2); }); it('lists a folder by its record key', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { body } = await api( pds, '/account/api/drive/files?folder=3photostid', { cookie }, ); expect(body.folder).toBe('3photostid'); expect(body.path).toBe('photos'); expect(body.ancestry).toEqual([{ rkey: '3photostid', name: 'photos' }]); expect(body.files.map((/** @type {any} */ f) => f.name)).toEqual([ 'shot.jpg', ]); expect(body.folders).toEqual([]); }); it('refuses a folder that is not there', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api( pds, '/account/api/drive/files?folder=nosuchfolder', { cookie }, ); expect(status).toBe(400); expect(body.error).toMatch(/not valid/); }); it('has no routes without the extension', async () => { const { pds } = await makePds({ drive: false }); const cookie = await signInCookie(pds); const response = await apiRaw(pds, '/account/api/drive/files', { cookie, }); expect(response.status).toBe(404); }); }); describe('/account/api/drive/file', () => { it('serves the bytes as an attachment', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const response = await apiRaw( pds, '/account/api/drive/file?rkey=3notestid', { cookie }, ); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-disposition')).toContain( 'filename="notes.txt"', ); expect(response.headers.get('x-content-type-options')).toBe('nosniff'); expect(new Uint8Array(await response.arrayBuffer())).toEqual(NOTES); }); it('rejects a missing rkey and an unknown file', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const unnamed = await api(pds, '/account/api/drive/file', { cookie }); expect(unnamed.status).toBe(400); const missing = await api(pds, '/account/api/drive/file?rkey=nope', { cookie, }); expect(missing.status).toBe(400); expect(missing.body.error).toMatch(/No such file/); }); }); describe('/account/api/drive/upload', () => { /** * @param {PersonalDataServer} pds * @param {string} path * @param {{cookie?: string, body?: BodyInit, type?: string, origin?: string}} opts */ async function send(pds, path, opts) { const response = await pds.fetch( new Request(`https://${PDS_HOST}${path}`, { method: 'POST', headers: { ...(opts.cookie ? { Cookie: opts.cookie } : {}), 'Content-Type': opts.type || 'text/plain', ...(opts.origin ? { Origin: opts.origin } : {}), }, body: opts.body, }), ); return { status: response.status, body: await response.json() }; } it('stores the body as a file and commits it', async () => { const { pds, commits } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await send( pds, '/account/api/drive/upload?name=hello.txt', { cookie, body: 'hello there' }, ); expect(status).toBe(200); expect(body).toMatchObject({ name: 'hello.txt', folder: '', mimeType: 'text/plain', size: 11, partCount: 1, }); expect(commits).toEqual([ [{ action: 'create', uri: body.uri, cid: body.cid }], ]); const listing = await api(pds, '/account/api/drive/files', { cookie }); expect(listing.body.files.map((/** @type {any} */ f) => f.name)).toContain( 'hello.txt', ); }); it('splits a body past the chunk size into several parts', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); // The harness sets a 64-byte chunk, so this needs three blobs. const { body } = await send(pds, '/account/api/drive/upload?name=big.bin', { cookie, body: 'x'.repeat(150), type: 'application/octet-stream', }); expect(body.partCount).toBe(3); expect(body.size).toBe(150); const download = await apiRaw( pds, `/account/api/drive/file?rkey=${body.rkey}`, { cookie }, ); expect(await download.text()).toBe('x'.repeat(150)); }); it('puts the file in the folder the query names', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { body } = await send( pds, '/account/api/drive/upload?name=note.txt&folder=3photostid', { cookie, body: 'notes' }, ); expect(body.folder).toBe('3photostid'); }); it('requires a session', async () => { const { pds } = await makePds(); const { status } = await send(pds, '/account/api/drive/upload?name=a.txt', { body: 'x', }); expect(status).toBe(401); }); it('refuses a cross-origin post', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status } = await send(pds, '/account/api/drive/upload?name=a.txt', { cookie, body: 'x', origin: 'https://evil.example', }); expect(status).toBe(403); }); it('rejects a name that is not one path segment', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await send( pds, '/account/api/drive/upload?name=a%2Fb.txt', { cookie, body: 'x' }, ); expect(status).toBe(400); expect(body.error).toMatch(/file name/); }); it('is blocked in read-only mode', async () => { const { pds } = await makePds({ readOnly: true }); const cookie = await signInCookie(pds); const { status } = await send(pds, '/account/api/drive/upload?name=a.txt', { cookie, body: 'x', }); expect(status).toBe(403); }); it('reports the feature off without a writer', async () => { const { pds } = await makePds({ upload: false }); const cookie = await signInCookie(pds); const { status, body } = await send( pds, '/account/api/drive/upload?name=a.txt', { cookie, body: 'x' }, ); expect(status).toBe(400); expect(body.error).toMatch(/not enabled/); }); }); describe('/account/api/drive/delete', () => { it('trashes the file, leaving the bytes where they are', async () => { const { pds, deleted, unlinked, reapCount } = await makePds(); const cookie = await signInCookie(pds); const { status } = await api(pds, '/account/api/drive/delete', { cookie, method: 'POST', body: { rkey: '3notestid' }, }); expect(status).toBe(200); // Nothing removed, nothing unlinked, no sweep: the bytes have to survive // for a restore to give them back. expect(deleted).toEqual([]); expect(unlinked).toEqual([]); expect(reapCount()).toBe(0); const listing = await api(pds, '/account/api/drive/files', { cookie }); expect( listing.body.files.map((/** @type {any} */ f) => f.name), ).not.toContain('notes.txt'); const trash = await api(pds, '/account/api/drive/trash', { cookie }); expect(trash.body.items.map((/** @type {any} */ i) => i.name)).toEqual([ 'notes.txt', ]); }); it('restores what it trashed', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); await api(pds, '/account/api/drive/delete', { cookie, method: 'POST', body: { rkey: '3notestid' }, }); const { status, body } = await api(pds, '/account/api/drive/restore', { cookie, method: 'POST', body: { rkey: '3notestid' }, }); expect(status).toBe(200); expect(body.restored).toBe(1); const listing = await api(pds, '/account/api/drive/files', { cookie }); expect(listing.body.files.map((/** @type {any} */ f) => f.name)).toContain( 'notes.txt', ); }); it('frees the bytes only when the trash is emptied', async () => { const { pds, deleted, unlinked, reapCount } = await makePds(); const cookie = await signInCookie(pds); const uri = `at://${DID}/dev.pdsjs.drive.file/3notestid`; await api(pds, '/account/api/drive/delete', { cookie, method: 'POST', body: { rkey: '3notestid' }, }); const { status, body } = await api(pds, '/account/api/drive/purge', { cookie, method: 'POST', body: { rkey: '3notestid' }, }); expect(status).toBe(200); expect(body.purged).toBe(1); expect(unlinked).toEqual([uri]); expect(deleted).toEqual([uri]); expect(reapCount()).toBe(1); }); it('rejects an unknown file and a missing rkey', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const missing = await api(pds, '/account/api/drive/delete', { cookie, method: 'POST', body: { rkey: 'nope' }, }); expect(missing.status).toBe(400); expect(missing.body.error).toMatch(/No such file/); const unnamed = await api(pds, '/account/api/drive/delete', { cookie, method: 'POST', body: {}, }); expect(unnamed.status).toBe(400); }); it('is blocked in read-only mode while listing still works', async () => { const { pds, deleted } = await makePds({ readOnly: true }); const cookie = await signInCookie(pds); const list = await api(pds, '/account/api/drive/files', { cookie }); expect(list.status).toBe(200); const removal = await api(pds, '/account/api/drive/delete', { cookie, method: 'POST', body: { rkey: '3notestid' }, }); expect(removal.status).toBe(403); expect(deleted).toHaveLength(0); }); }); describe('/account/api/drive/file ranges', () => { it('advertises ranges and serves one', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const whole = await apiRaw(pds, '/account/api/drive/file?rkey=3notestid', { cookie, }); expect(whole.headers.get('accept-ranges')).toBe('bytes'); const response = await pds.fetch( new Request(`https://${PDS_HOST}/account/api/drive/file?rkey=3notestid`, { headers: { Cookie: cookie, Range: 'bytes=4-7' }, }), ); expect(response.status).toBe(206); expect(response.headers.get('content-range')).toBe( `bytes 4-7/${NOTES.length}`, ); expect(await response.text()).toBe( new TextDecoder().decode(NOTES.subarray(4, 8)), ); }); it('serves a suffix range', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const response = await pds.fetch( new Request(`https://${PDS_HOST}/account/api/drive/file?rkey=3notestid`, { headers: { Cookie: cookie, Range: 'bytes=-3' }, }), ); expect(response.status).toBe(206); expect(await response.text()).toBe( new TextDecoder().decode(NOTES.subarray(NOTES.length - 3)), ); }); it('answers 416 for a range past the end', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const response = await pds.fetch( new Request(`https://${PDS_HOST}/account/api/drive/file?rkey=3notestid`, { headers: { Cookie: cookie, Range: 'bytes=999-1200' }, }), ); expect(response.status).toBe(416); expect(response.headers.get('content-range')).toBe( `bytes */${NOTES.length}`, ); }); }); describe('/account/api/drive/folder', () => { it('makes, renames and removes a folder', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const made = await api(pds, '/account/api/drive/folder', { cookie, method: 'POST', body: { name: 'plans' }, }); expect(made.status).toBe(200); expect(made.body.name).toBe('plans'); const renamed = await api(pds, '/account/api/drive/folder/rename', { cookie, method: 'POST', body: { rkey: made.body.rkey, name: 'roadmap' }, }); expect(renamed.status).toBe(200); expect(renamed.body.name).toBe('roadmap'); const listing = await api( pds, `/account/api/drive/files?folder=${made.body.rkey}`, { cookie }, ); expect(listing.body.path).toBe('roadmap'); const removed = await api(pds, '/account/api/drive/folder/delete', { cookie, method: 'POST', body: { rkey: made.body.rkey }, }); expect(removed.status).toBe(200); }); it('rejects an unnamed folder and one that is not there', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const unnamed = await api(pds, '/account/api/drive/folder', { cookie, method: 'POST', body: {}, }); expect(unnamed.status).toBe(400); const missing = await api(pds, '/account/api/drive/folder/delete', { cookie, method: 'POST', body: { rkey: 'nosuch' }, }); expect(missing.status).toBe(400); }); it('is blocked in read-only mode', async () => { const { pds } = await makePds({ readOnly: true }); const cookie = await signInCookie(pds); const { status } = await api(pds, '/account/api/drive/folder', { cookie, method: 'POST', body: { name: 'plans' }, }); expect(status).toBe(403); }); }); describe('/account/api/drive/changes', () => { it('tells a caller with no anchor to enumerate', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api(pds, '/account/api/drive/changes', { cookie, }); expect(status).toBe(200); expect(body.reenumerate).toBe(true); expect(typeof body.anchor).toBe('number'); }); it('tells a caller watching a space to enumerate', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { body } = await api( pds, '/account/api/drive/changes?since=0&space=at%3A%2F%2Fx%2Fspace%2Fa%2Fb', { cookie }, ); // A space keeps its own commit log, which the repo sequence does not cover. expect(body.reenumerate).toBe(true); expect(body.anchor).toBe(null); }); it('rejects an anchor that is not a number', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status } = await api(pds, '/account/api/drive/changes?since=abc', { cookie, }); expect(status).toBe(400); }); }); describe('the change feed as a client would walk it', () => { it('reports what changed since an anchor, and what went away', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const first = await api(pds, '/account/api/drive/changes', { cookie }); const anchor = first.body.anchor; const made = await api(pds, '/account/api/drive/folder', { cookie, method: 'POST', body: { name: 'plans' }, }); const removal = await api(pds, '/account/api/drive/delete', { cookie, method: 'POST', body: { rkey: '3notestid' }, }); expect(removal.status).toBe(200); const delta = await api(pds, `/account/api/drive/changes?since=${anchor}`, { cookie, }); expect(delta.body.reenumerate).toBe(false); expect(delta.body.changed).toContain( `dev.pdsjs.drive.folder/${made.body.rkey}`, ); expect(delta.body.deleted).toContain('dev.pdsjs.drive.file/3notestid'); expect(delta.body.anchor).toBeGreaterThan(anchor); }); it('reports nothing when nothing has happened', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const first = await api(pds, '/account/api/drive/changes', { cookie }); const again = await api( pds, `/account/api/drive/changes?since=${first.body.anchor}`, { cookie }, ); expect(again.body.changed).toEqual([]); expect(again.body.deleted).toEqual([]); }); }); describe('which spaces a drive is offered', () => { const uriFor = (/** @type {string} */ type, /** @type {string} */ key) => `at://${DID}/space/${type}/${key}`; it("offers only spaces made for a drive, never another app's", async () => { const { pds } = await makePds({ spaces: [ // An app's own space holds that app's data model, and its members // read what is in it. A file put there is squatting and is shared. { uri: uriFor('app.bulleted.space', 'self'), spaceType: 'app.bulleted.space', }, { uri: uriFor('sh.tangled.repo', '3msw4lgrmisi4'), spaceType: 'sh.tangled.repo', }, { uri: uriFor('dev.pdsjs.drive.space', 'photos'), spaceType: 'dev.pdsjs.drive.space', }, ], }); const cookie = await signInCookie(pds); const { body } = await api(pds, '/account/api/drive/files', { cookie }); expect(body.spaces).toEqual([ { uri: uriFor('dev.pdsjs.drive.space', 'photos'), type: 'dev.pdsjs.drive.space', key: 'photos', name: 'photos', }, ]); }); it('names a drive by the key its owner chose', async () => { const { pds } = await makePds({ spaces: [ { uri: uriFor('dev.pdsjs.drive.space', 'photos'), spaceType: 'dev.pdsjs.drive.space', }, { uri: uriFor('dev.pdsjs.drive.space', 'work'), spaceType: 'dev.pdsjs.drive.space', }, ], }); const cookie = await signInCookie(pds); const { body } = await api(pds, '/account/api/drive/files', { cookie }); // Every drive space shares one type, so the key is what tells them apart. expect(body.spaces.map((/** @type {any} */ s) => s.name)).toEqual([ 'photos', 'work', ]); }); it('leaves out a deleted drive', async () => { const { pds } = await makePds({ spaces: [ { uri: uriFor('dev.pdsjs.drive.space', 'gone'), spaceType: 'dev.pdsjs.drive.space', deletedAt: '2026-08-15T00:00:00.000Z', }, ], }); const cookie = await signInCookie(pds); const { body } = await api(pds, '/account/api/drive/files', { cookie }); expect(body.spaces).toEqual([]); }); }); describe('/account/api/drive/space', () => { it('makes a space of the drive type, not of any other', async () => { const { pds, created } = await makePds({ spaces: [] }); const cookie = await signInCookie(pds); const { status, body } = await api(pds, '/account/api/drive/space', { cookie, method: 'POST', body: { name: 'photos' }, }); expect(status).toBe(200); expect(created).toEqual([ { type: 'dev.pdsjs.drive.space', skey: 'photos' }, ]); expect(body).toMatchObject({ key: 'photos', name: 'photos' }); }); it('refuses a name that would not read as one', async () => { const { pds } = await makePds({ spaces: [] }); const cookie = await signInCookie(pds); for (const name of ['', 'Photos', 'my photos', 'a/b', '-lead']) { const { status } = await api(pds, '/account/api/drive/space', { cookie, method: 'POST', body: { name }, }); expect(status).toBe(400); } }); it('reports the feature off where spaces are not enabled', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api(pds, '/account/api/drive/space', { cookie, method: 'POST', body: { name: 'photos' }, }); expect(status).toBe(400); expect(body.error).toMatch(/not enabled/); const listing = await api(pds, '/account/api/drive/files', { cookie }); expect(listing.body.canMakeSpace).toBe(false); }); it('is blocked in read-only mode', async () => { const { pds } = await makePds({ spaces: [], readOnly: true }); const cookie = await signInCookie(pds); const { status } = await api(pds, '/account/api/drive/space', { cookie, method: 'POST', body: { name: 'photos' }, }); expect(status).toBe(403); }); }); describe('/account/api/drive/upload sessions', () => { /** * @param {PersonalDataServer} pds * @param {string} id * @param {string} cookie * @param {string} text */ async function sendPart(pds, id, cookie, text) { const response = await pds.fetch( new Request( `https://${PDS_HOST}/account/api/drive/upload/part?id=${id}`, { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'application/octet-stream', }, body: text, }, ), ); return { status: response.status, body: await response.json() }; } it('takes a file across several requests and lands it as one blob', async () => { const { pds, records } = await makePds({ streaming: true }); const cookie = await signInCookie(pds); const started = await api(pds, '/account/api/drive/upload/start', { cookie, method: 'POST', body: { name: 'clip.mp4', mimeType: 'video/mp4' }, }); expect(started.status).toBe(200); const { id } = started.body; const first = await sendPart(pds, id, cookie, 'the first part '); expect(first.status).toBe(200); expect(first.body.received).toBe(15); const second = await sendPart(pds, id, cookie, 'and the second'); expect(second.body.received).toBe(29); const finished = await api(pds, '/account/api/drive/upload/finish', { cookie, method: 'POST', body: { id }, }); expect(finished.status).toBe(200); expect(finished.body).toMatchObject({ name: 'clip.mp4', mimeType: 'video/mp4', size: 29, partCount: 1, }); expect(records.has(finished.body.uri)).toBe(true); }); it('advertises the part size only when the port streams', async () => { const streaming = await makePds({ streaming: true }); const cookie = await signInCookie(streaming.pds); const listing = await api( streaming.pds, '/account/api/drive/files?folder=', { cookie, }, ); expect(listing.body.uploadPartSize).toBeGreaterThan(0); const buffered = await makePds(); const other = await signInCookie(buffered.pds); const plain = await api(buffered.pds, '/account/api/drive/files?folder=', { cookie: other, }); expect(plain.body.uploadPartSize).toBe(null); }); it('reports the feature off without a streaming blob port', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api(pds, '/account/api/drive/upload/start', { cookie, method: 'POST', body: { name: 'a.bin' }, }); expect(status).toBe(400); expect(body.error).toMatch(/in parts/); }); it('abort forgets the session', async () => { const { pds } = await makePds({ streaming: true }); const cookie = await signInCookie(pds); const { body: started } = await api( pds, '/account/api/drive/upload/start', { cookie, method: 'POST', body: { name: 'gone.bin' } }, ); await sendPart(pds, started.id, cookie, 'some bytes'); const aborted = await api(pds, '/account/api/drive/upload/abort', { cookie, method: 'POST', body: { id: started.id }, }); expect(aborted.body).toEqual({ aborted: true }); const finished = await api(pds, '/account/api/drive/upload/finish', { cookie, method: 'POST', body: { id: started.id }, }); expect(finished.status).toBe(400); expect(finished.body.error).toMatch(/No such upload/); }); });