diff options
Diffstat (limited to 'packages/meshbay-hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 48 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 119 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/downloads.js | 81 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/sw.js | 60 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 39 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_downloads.py | 56 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_membership.py | 114 |
8 files changed, 509 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 83feeb4..74c6c9e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -276,6 +276,54 @@ async def create_group( return {"group_id": group.id, "name": group.name} +@router.delete("/{group_id}/members/{username}") +async def remove_group_member( + group_id: str, + username: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Remove someone from a group. The group's owner only. + + This is half of removing a member, and the half the hub can do: without a + membership row they cannot reach the node through signaling, and their next + token will not name this group. What it does not do is make the node forget + them — the node's roster decides who it serves, and only a paired operator + can change that (`member_revoke` over MNP, or `meshbay-node member revoke`). + The browser does both; a caller using this endpoint alone should know it did + one. + """ + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != current_user.id: + raise HTTPException(status_code=403, + detail="Only the group owner can remove members") + + target = (await db.execute( + select(User).where(User.username == username))).scalar_one_or_none() + if not target: + raise HTTPException(status_code=404, detail="User not found") + if target.id == group.admin_id: + raise HTTPException( + status_code=409, + detail="The owner cannot be removed from their own group. Hand the " + "group over or delete it.") + + membership = await db.get(GroupMember, (group_id, target.id)) + if not membership: + raise HTTPException(status_code=404, detail="Not a member of this group") + + await db.delete(membership) + db.add(IPLog(user_id=current_user.id, event="group_leave", + ip_address=client_ip(request), + detail=f"{username} removed from {group.name}")) + await db.commit() + return {"status": "removed", "group_id": group_id, "username": username} + + class GroupUpdateRequest(BaseModel): description: str | None = None diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c8fb694..18ce83a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -941,6 +941,10 @@ function canPreview(e) { } const CHUNK_SIZE = 1024 * 1024; +// Seconds of already-watched video kept in the SourceBuffer, and the queue depth +// past which we start making room before being forced to. +const BUFFER_BEHIND_S = 60; +const QUEUE_HIGH_WATER = 12; const PIPELINE_WINDOW = 8; /** @@ -950,7 +954,8 @@ const PIPELINE_WINDOW = 8; * it and hand the browser a blob", or false for "the person dismissed the * dialog", which is not an error and must not start a transfer. */ -async function _openDownloadTarget(filename, size = 0, pickerOpts = {}) { +async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, + swSize = size) { try { const target = await downloads.openTarget(filename); if (target) return target; @@ -958,12 +963,16 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}) { console.warn('[MeshBay] download folder unusable:', err.message); } - // No granted folder. Saving automatically means not putting a dialog in the - // way, so anything that fits in memory goes to the browser's own download - // folder — which is what "automatic" meant to whoever chose the setting. - // Past that a blob would take the tab down with it, and one dialog is the - // lesser evil; Settings is where to stop it happening again. - if (downloads.getMode() === 'auto' && size < downloads.BLOB_LIMIT) return null; + // No granted folder. A service worker can still hand the browser a stream to + // write, which is how this works at all in Firefox: the alternative there is + // to collect gigabytes in a tab. It goes to the browser's own download + // folder, without a dialog, which is what "save automatically" meant. + if (downloads.getMode() === 'auto') { + const streamed = await downloads.openStreamedDownload(filename, swSize); + if (streamed) return streamed; + // Nothing to stream to: small enough for memory, and no dialog. + if (size < downloads.BLOB_LIMIT) return null; + } if (!window.showSaveFilePicker) return null; try { @@ -1331,10 +1340,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); const suggested = (dir.split('/').pop() || 'files') + '.zip'; + // totalBytes decides how this is delivered, but it is not the archive's + // size — headers and the central directory come on top — so it is not + // announced as a Content-Length that the download would then miss. const target = await _openDownloadTarget(suggested, totalBytes, { types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], - }); + }, 0); if (target === false) return; if (!target && !confirm(t('group.zip_no_stream', { size: formatSize(totalBytes), name: suggested, @@ -1970,6 +1982,39 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, } }, [pairCode, transportRef, userId]); + const [removing, setRemoving] = useState(''); + + /** + * Take someone out of this group: both halves, in the order that fails safe. + * + * The node first, because that is the half that stops the group key being + * wrapped for them; if the hub removal then fails, they are a member on paper + * with no key. The other order would leave them able to reach a node that + * still serves them. + */ + const removeMember = useCallback(async (member) => { + const transport = transportRef && transportRef.current; + setError(''); + setRemoving(member.user_id); + try { + if (transport && transport.connected && operatorPaired) { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.revokeMember(member.user_id, signFn); + } + await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { + method: 'DELETE', token, + }); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setRemoving(''); + } + }, [groupId, token, transportRef, operatorPaired]); + const loadMembers = useCallback(() => { setLoading(true); hubFetch(`/v1/groups/${groupId}/members`, { token }) @@ -2067,6 +2112,7 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, <tr> <th>${t('admin.col_username')}</th> <th>${t('members.group_role')}</th> + <th></th> </tr> </thead> <tbody> @@ -2079,10 +2125,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, : html`<span class="badge">${t('members.member')}</span>` } </td> + <td class="admin-actions"> + ${isAdmin && m.user_id !== adminId && html` + <button class="admin-btn danger" disabled=${removing === m.user_id} + onClick=${() => { + if (!confirm(t('members.remove_confirm', { user: m.username }))) return; + removeMember(m); + }}> + ${removing === m.user_id ? '...' : t('members.remove')} + </button> + `} + </td> </tr> `)} </tbody> </table> + ${isAdmin && members.length > 1 && html` + <p class="settings-hint">${t('members.remove_hint')}</p> + `} ${isNodeAdmin && !operatorPaired && html` <form class="invite-form" onSubmit=${doPair}> <h4>${t('members.pair_title')}</h4> @@ -2379,6 +2439,28 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { const endedRef = useRef(false); const durationRef = useRef(0); + /** + * Drop what has already been watched. + * + * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes + * and refuse the append that goes past. Keeping a minute behind the playhead + * is enough for a small seek backwards and bounded for a three-hour film. + */ + const evictBehind = useCallback(() => { + const sb = sbRef.current; + const v = videoRef.current; + if (!sb || !v || sb.updating || !sb.buffered.length) return false; + const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S); + const start = sb.buffered.start(0); + if (keepFrom - start < 10) return false; + try { + sb.remove(start, keepFrom); + return true; + } catch { + return false; + } + }, []); + const flushQueue = useCallback(() => { const sb = sbRef.current; if (!sb || appendingRef.current || sb.updating) return; @@ -2389,14 +2471,25 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { return; } appendingRef.current = true; - const chunk = queueRef.current.shift(); + const chunk = queueRef.current[0]; try { sb.appendBuffer(chunk); + queueRef.current.shift(); } catch (e) { appendingRef.current = false; + if (e.name === 'QuotaExceededError') { + // The segment stays at the head of the queue and is tried again once + // there is room. Dropping it — which is what this used to do — leaves a + // hole in the middle of the film and no error anywhere. + if (!evictBehind()) { + console.warn('[MSE] buffer full and nothing to evict yet'); + } + return; + } + queueRef.current.shift(); console.error('[MSE] appendBuffer error:', e); } - }, []); + }, [evictBehind]); useEffect(() => { let cancelled = false; @@ -2445,6 +2538,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { sb.mode = 'sequence'; sb.addEventListener('updateend', () => { appendingRef.current = false; + // One segment consumed, so the node may send one more. The credit + // is granted here, after the append, because this is the point at + // which the memory is genuinely free again. + const transport = transportRef.current; + if (transport && transport.connected) transport.grantStreamCredit(1); + if (queueRef.current.length > QUEUE_HIGH_WATER) evictBehind(); flushQueue(); }); setPhase('streaming'); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index a71f289..7f48fed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -171,8 +171,83 @@ export async function openTarget(filename) { } /** - * Below this, a download with no granted folder is collected in memory and - * handed to the browser, which saves it without asking. Above it that would - * mean holding gigabytes in a tab, so it is worth one Save As dialog instead. + * Below this, a download with no granted folder and no service worker is + * collected in memory and handed to the browser. Above it that would mean + * holding gigabytes in a tab, so it is worth one Save As dialog instead. */ export const BLOB_LIMIT = 512 * 1024 * 1024; + +// ── Streaming to disk without the File System Access API ──────────────────── + +const SW_PATH = '/sw.js'; +let _swReady = null; + +export const STREAMS_VIA_SW = typeof window !== 'undefined' + && 'serviceWorker' in navigator + && typeof TransformStream === 'function' + && window.isSecureContext; + +async function serviceWorker() { + if (!STREAMS_VIA_SW) return null; + if (!_swReady) { + _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' }) + .then(() => navigator.serviceWorker.ready) + .then(reg => reg.active || navigator.serviceWorker.controller) + .catch(err => { + console.warn('[MeshBay] service worker unavailable:', err.message); + return null; + }); + } + return _swReady; +} + +/** + * A sink the browser writes to disk, for Firefox and anything else without the + * File System Access API. + * + * The page keeps the writable half of a stream and gives the readable half to + * the service worker, which answers a made-up URL with it. Navigating a hidden + * iframe there turns it into an ordinary download: written as it arrives, with + * the browser's own progress, and nothing held in the tab. Backpressure is + * real — `writer.write()` waits when the browser is behind. + * + * Returns {writable, name} shaped like the File System Access one, or null if + * this browser cannot do it either. + */ +export async function openStreamedDownload(filename, size = 0) { + const worker = await serviceWorker(); + if (!worker) return null; + + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const { readable, writable } = new TransformStream(); + + try { + worker.postMessage({ type: 'mbdl', id, filename, size, readable }, [readable]); + } catch (err) { + // Transferable streams are what makes the backpressure work; without them + // this would be a memory buffer wearing a stream's clothes. + console.warn('[MeshBay] streams cannot be transferred here:', err.message); + return null; + } + + const frame = document.createElement('iframe'); + frame.hidden = true; + frame.src = `/_mbdl/${id}`; + document.body.appendChild(frame); + + const writer = writable.getWriter(); + return { + name: filename, + writable: { + write: (bytes) => writer.write(bytes), + close: async () => { + await writer.close(); + setTimeout(() => frame.remove(), 2000); + }, + abort: async (reason) => { + try { await writer.abort(reason); } catch { /* already gone */ } + frame.remove(); + }, + }, + }; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index a2d13c5..71f6cce 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -284,6 +284,13 @@ const en = { + 'pinned for your browser, so pair it below before inviting anyone.', 'members.invite_ask_operator': 'Only the operator of the node hosting this group can ' + 'invite, from a browser paired with it.', + 'members.remove': 'Remove', + 'members.remove_confirm': 'Remove {user} from this group? Their account, their ' + + 'other groups and the files they uploaded are not affected.', + 'members.remove_hint': 'Removing someone takes away their membership and stops ' + + 'the node serving them the group key. It does not delete their account, and ' + + 'it does not take back the key they already hold — rotate it on the node ' + + '(meshbay-node gek-init) if that matters.', 'members.pair_title': 'Pair this browser with your node', 'members.pair_hint': 'Your node only accepts operator actions — invites, file ' + 'deletion — from a browser it has been paired with. Run ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js new file mode 100644 index 0000000..d0a8805 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js @@ -0,0 +1,60 @@ +/** + * Service worker: the only way to stream a download to disk in Firefox. + * + * Chrome and Edge have the File System Access API — the page opens a file and + * writes to it. Firefox and Safari do not, and the alternative there was to + * collect the whole download in memory and hand the browser a blob, which is + * not an option for a file measured in gigabytes. + * + * So the page makes up a URL, tells this worker what stream answers it, and + * navigates a hidden iframe there. The worker replies with the stream and a + * Content-Disposition header, and the browser does what it does with any + * download: writes it to disk as it arrives, showing its own progress, with + * nothing buffered in the tab. + * + * It caches nothing and intercepts nothing else. Every request that is not one + * of these downloads falls through untouched. + */ + +const PREFIX = '/_mbdl/'; +const pending = new Map(); + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); + +self.addEventListener('message', (event) => { + const data = event.data || {}; + if (data.type !== 'mbdl' || !data.id || !data.readable) return; + pending.set(data.id, { + readable: data.readable, + filename: data.filename || 'download', + size: Number(data.size) || 0, + }); + // A tab that is closed before it navigates would leave a stream here for the + // life of the worker. + setTimeout(() => pending.delete(data.id), 60000); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(PREFIX)) { + return; // not ours — the network handles it + } + + const entry = pending.get(url.pathname.slice(PREFIX.length)); + if (!entry) return; + pending.delete(url.pathname.slice(PREFIX.length)); + + const headers = { + 'Content-Type': 'application/octet-stream', + // filename* so a name with accents or spaces survives the trip. + 'Content-Disposition': + `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`, + 'Cache-Control': 'no-store', + }; + // Only when it is known. A zip is assembled as it goes and announcing a + // length we then miss would truncate the file. + if (entry.size > 0) headers['Content-Length'] = String(entry.size); + + event.respondWith(new Response(entry.readable, { headers })); +}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 24c08b9..844a201 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -42,6 +42,10 @@ const UPLOAD_CHUNK_SIZE = 48 * 1024; const UPLOAD_WINDOW = 32; const UPLOAD_BUFFER_HIGH = 1024 * 1024; +// Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a +// slow link and small enough that nothing accumulates. +const STREAM_CREDITS = 24; + function _aborted() { const err = new Error('Cancelled'); err.name = 'AbortError'; @@ -522,8 +526,39 @@ class MeshBayTransport { return msg; } - requestStream(fileId) { - this._send({ type: 'stream_req', v: '0.1', file_id: fileId }); + /** + * Stop this node serving the group key to someone. Operator only. + * + * Only the node can do this: its roster decides who it serves. Removing them + * on the hub is the other half, and neither implies the other. + */ + async revokeMember(userId, signFn) { + const msg = await this._sendAndWait({ + type: 'member_revoke', v: '0.1', user_id: userId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'member_revoke', userId, signFn); + } + return msg; + } + + /** + * Ask for a video stream, and say how much we can take. + * + * `credits` bounds what is in flight. Without it the node pushes the whole + * film as fast as ffmpeg produces it and the browser holds all of it while + * MediaSource consumes a segment at a time — which is fine for a clip and + * fatal for anything worth streaming. + */ + requestStream(fileId, credits = STREAM_CREDITS) { + this._send({ type: 'stream_req', v: '0.1', file_id: fileId, credits }); + } + + /** Room for `n` more segments. */ + grantStreamCredit(n = 1) { + if (!this._connected) return; + this._send({ type: 'stream_more', v: '0.1', n }); } /** diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index cd0cded..41d61bf 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -108,3 +108,59 @@ def test_the_open_action_reads_the_file_back(tmp_path): target = src[src.index("export async function openTarget"):] assert "getFile()" in target and "window.open(" in target assert "revokeObjectURL" in target, "the blob URL must not be leaked" + + +# ── Streaming to disk without the File System Access API ──────────────────── + +SW = STATIC / "sw.js" + + +def test_the_worker_only_answers_its_own_urls(): + """ + It is registered at the root scope, so it sees every request the page makes. + Anything that is not a download of ours has to fall through untouched — a + service worker that answers more than it should is a cache bug waiting to + happen. + """ + src = SW.read_text() + assert "startsWith(PREFIX)" in src + assert "self.location.origin" in src, "cross-origin requests must fall through" + # The API, not the word: the file explains in prose that it caches nothing. + for api in ("caches.open", "caches.match", "cache.put"): + assert api not in src, f"this worker must not cache anything ({api})" + + +def test_the_download_is_announced_as_an_attachment(): + src = SW.read_text() + assert "Content-Disposition" in src and "attachment" in src + assert "filename*=UTF-8''" in src, "a name with accents would be mangled" + assert "Content-Length" in src + + +def test_a_length_is_only_promised_when_it_is_known(tmp_path): + """ + An archive is assembled as it goes and is larger than the files in it. + Announcing the sum of their sizes would truncate the download at that mark. + """ + src = SW.read_text() + assert "if (entry.size > 0)" in src + + app = (STATIC / "app.js").read_text() + zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):] + zip_call = zip_call[:zip_call.index(");") + 2] + assert zip_call.rstrip().endswith(", 0);"), ( + "the zip download announces a Content-Length it will not match") + + +def test_backpressure_is_real(tmp_path): + """ + The point of the service worker path is not holding the file. A stream that + is transferred gives `writer.write()` something to wait on; posting chunks + to a port would queue them in memory and look identical from here. + """ + src = DOWNLOADS.read_text() + fn = src[src.index("export async function openStreamedDownload"):] + assert "new TransformStream()" in fn + assert "[readable]" in fn, "the readable half must be transferred, not copied" + assert "writer.write(bytes)" in fn + assert "return null" in fn, "a browser that cannot transfer streams must say so" diff --git a/packages/meshbay-hub/tests/test_group_membership.py b/packages/meshbay-hub/tests/test_group_membership.py new file mode 100644 index 0000000..7eeaa2f --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_membership.py @@ -0,0 +1,114 @@ +""" +Removing someone from a group. + +The distinction these pin is the one that matters: removing a member from a +group is not deleting their account. It takes away one membership row, and +leaves the person, their other groups and everything they have uploaded exactly +where they were. +""" + +import base64 +import hashlib + +import pytest +from sqlalchemy import select + +from meshbay_hub.db.models import Group, GroupMember, User + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _user(client, username, password="a-long-enough-passphrase"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(password, username)}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(password, username)}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +async def _group_with_member(client, owner, member_name, name="crew"): + g = await client.post("/v1/groups", json={"name": name}, headers=owner) + gid = g.json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/{member_name}", json={}, + headers=owner) + return gid + + +@pytest.mark.asyncio +async def test_the_owner_removes_a_member(client, db_session): + owner = await _user(client, "chief") + await _user(client, "hanger_on") + gid = await _group_with_member(client, owner, "hanger_on") + + r = await client.delete(f"/v1/groups/{gid}/members/hanger_on", headers=owner) + assert r.status_code == 200, r.text + + rows = (await db_session.execute( + select(GroupMember).where(GroupMember.group_id == gid))).scalars().all() + assert [m.user_id for m in rows] != [], "the owner lost their own membership" + names = {(await db_session.get(User, m.user_id)).username for m in rows} + assert names == {"chief"} + + +@pytest.mark.asyncio +async def test_removing_a_member_is_not_deleting_an_account(client, db_session): + """ + The account survives untouched, with its other groups. Anything else would + make one group's owner able to erase someone from the whole hub. + """ + owner = await _user(client, "boss") + member = await _user(client, "member_x") + elsewhere = await _user(client, "other_owner") + + gid = await _group_with_member(client, owner, "member_x") + other = await _group_with_member(client, elsewhere, "member_x", name="elsewhere") + + await client.delete(f"/v1/groups/{gid}/members/member_x", headers=owner) + + user = (await db_session.execute( + select(User).where(User.username == "member_x"))).scalar_one() + assert user.status == "active", "the account was touched" + + me = await client.get("/v1/users/me", headers=member) + assert me.status_code == 200, "they can no longer sign in" + + still = await db_session.get(GroupMember, (other, user.id)) + assert still is not None, "removing them from one group emptied another" + + +@pytest.mark.asyncio +async def test_a_member_cannot_remove_anyone(client): + owner = await _user(client, "owner_y") + member = await _user(client, "member_y") + await _user(client, "victim_y") + gid = await _group_with_member(client, owner, "member_y") + await client.post(f"/v1/groups/{gid}/members/victim_y", json={}, headers=owner) + + r = await client.delete(f"/v1/groups/{gid}/members/victim_y", headers=member) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_the_owner_cannot_be_removed_from_their_own_group(client): + """Otherwise the group is left with nobody who can invite or remove.""" + owner = await _user(client, "owner_z") + gid = await _group_with_member(client, owner, "owner_z") + + r = await client.delete(f"/v1/groups/{gid}/members/owner_z", headers=owner) + assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_removing_someone_who_is_not_a_member_says_so(client): + owner = await _user(client, "owner_w") + await _user(client, "stranger") + g = await client.post("/v1/groups", json={"name": "closed"}, headers=owner) + gid = g.json()["group_id"] + + r = await client.delete(f"/v1/groups/{gid}/members/stranger", headers=owner) + assert r.status_code == 404 |