diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-05 11:53:57 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-05 11:53:57 +0200 |
| commit | 8f25294b0f6bc3f292442edd69a2e149f0717b52 (patch) | |
| tree | 58cfe661b15fce395ab5116f19341bdb6a5b07fe | |
| parent | 28752696f376eb11feb686580a435b166750a723 (diff) | |
| download | meshbay-8f25294b0f6bc3f292442edd69a2e149f0717b52.tar.gz | |
feat: open a group, a folder or a file from a #/name@owner link
A group can now be reached by the handle shown under its name, and a path
after it points inside the group: #/name@owner/root/dir/file downloads the
file and opens Files on its folder; a folder opens Files there. The handle
is resolved in the client against the account's own /v1/groups/mine, so no
hub route answers for a name and nobody can probe for one. While a group is
open the address shows the handle (replace, no history entry); a linked path
is taken out of the address once acted on, so a reload does not download
twice.
Signing in no longer sends everyone home: the form stood in for the page the
address named, and that is where a link opened signed out was going.
group-link.js holds the parsing and lookups, executed whole by
test_group_link.py; harness/group_link_probe.py drives the router in Chrome.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed, 650 insertions, 12 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 78a9cb5..7c5dce9 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2088,10 +2088,17 @@ A group's **identity is its UUID**, everywhere: the route, the node's configurat membership. A group **name is unique per owner account**, case-insensitively and trimmed, enforced by a functional unique index; two different owners may each have a `photos`. Names are displayed as `name@owner`, which is a label plus a create-time -check and **not an addressing scheme**. The handle is hub-local: the same +check and **not an identity**. The handle is hub-local: the same `name@owner` on two federated hubs are different groups, and a federated row shows its source hub rather than an account. +The client also accepts the handle in the address, as an alias for the UUID +(§8.4): `#/name@owner`, optionally followed by a path inside the group. It is +resolved **in the client, against the account's own `/v1/groups/mine`**, and no +hub route answers "which group is called this" — so a handle tells nobody +anything they could not already see, and cannot be used to probe for a group. +A rename breaks the handle links to a group and none of its `#/group/<id>` ones. + `visibility` and `join_policy` are the two independent axes described in §3.5. `join_policy` is read from the node's own configuration, never from the hub. @@ -2545,6 +2552,26 @@ that decides where the hub is or fetches the API relative to the page origin. That is a testable invariant, and it is what any feature adding third-party egress must preserve — which is one of the reasons enrichment is node-side (§6.5). +**Inside the application, every route is a fragment** (`#/…`). What follows `#` +is never sent to a server, so it is in no hub or proxy log and no `Referer`; +that is what lets an invitation carry its code (§3.4), and it is why the same +router runs unchanged on `app://meshbay` and in the Android WebView, where no +server could answer a path. Two forms name a group: + +| Route | Meaning | +|---|---| +| `#/group/<uuid>` | the group — every link the application draws | +| `#/name@owner` | the same group by its handle (§7.3); the address shows this form while a group is open, written with `replace` so it is not a history entry | +| `#/name@owner/<root>/<dir>/<file>` | a file: Files opens on its folder and the file is downloaded. A folder instead of a file opens Files there. The path is taken out of the address once acted on, so a reload does not download twice | + +The owner is after the **last** `@` (a username cannot contain one); each path +segment is percent-decoded on its own. Opened signed out, the sign-in form +stands in for the page and the address is left alone, so signing in lands on +it. A download started this way has no user gesture behind it, so where a browser +offers a Save As dialog it takes the fallback a dialog refused for want of a +gesture already takes (`file-utils.js` `_openDownloadTarget`): streamed to the +download folder. `static/group-link.js`. + ### 8.5 Downloads and streaming **Downloads go to disk, never through RAM, on every platform.** There are three diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 97ea679..d7f0624 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -297,6 +297,13 @@ file browser — sort, select, download, preview. - **Right-click a file or folder** for the same actions as the toolbar, listing only the ones that apply to it. On a ticked row the menu acts on everything ticked, like the toolbar does. +- **A link to a group, a folder or a file.** While a group is open the address + bar shows `https://<hub>/#/name@owner` — the name under the group's title. + Add a path after it to point inside the group: + `#/name@owner/root/folder/photo.jpg` downloads that file, and a folder opens + Files there. Only members get anywhere with such a link — anyone else is told + the group is unknown — and someone not signed in is asked to sign in first, + then taken where the link pointed. Renaming the group breaks these links. ### Chat diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c5f2733..bdab271 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -21,6 +21,7 @@ import { } from './hub-client.js'; import { startIdleWatch, markActive } from './idle.js'; import { GroupPage } from './group-page.js'; +import { parseGroupLink, groupLinkRoute, findLinkedGroup } from './group-link.js'; import { lazy } from './lazy.js'; import { ConnectionPool } from './connection-pool.js'; import { MusicPlayerBar } from './music-player.js'; @@ -766,6 +767,9 @@ function App() { const [needsHub, setNeedsHub] = useState( platform.isNative && !platform.hubBase()); const [groups, setGroups] = useState([]); + // Whether `/mine` has answered for this sign-in. A `#/name@owner` link can + // only be called unknown once the list it is looked up in has arrived. + const [groupsLoaded, setGroupsLoaded] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); @@ -1008,11 +1012,13 @@ function App() { if (!user) { nodeKeyAskedForRef.current = null; setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); + setGroupsLoaded(false); return; } hubFetch('/v1/groups/mine', { token: user.token }) .then(data => setGroups(data.groups || [])) - .catch(() => setGroups([])); + .catch(() => setGroups([])) + .finally(() => setGroupsLoaded(true)); hubFetch('/v1/users/me/preferences', { token: user.token }) .then(async (prefs) => { setUserPrefs(prefs || {}); @@ -1233,6 +1239,31 @@ function App() { // out as the only way back. const refreshAuth = useCallback(() => refreshAccessToken(), []); + // A group is reached by its id (`#/group/<id>`, what every link inside the + // application uses) or by its handle (`#/name@owner[/path]`, the one a person + // types or shares — group-link.js). Both open the same page; `groupRoute` is + // the first form whichever was used, so the sidebar and the page see one. + const groupLink = route.startsWith('/group/') ? null : parseGroupLink(route); + const linkedGroup = groupLink ? findLinkedGroup(groups, groupLink) : null; + const groupId = route.startsWith('/group/') ? route.slice(7) + : linkedGroup ? linkedGroup.id : null; + const groupRoute = groupId ? '/group/' + groupId : route; + const shownGroup = groupId ? groups.find(g => g.id === groupId) : null; + const linkPath = groupLink && linkedGroup ? groupLink.path : ''; + // The address shows the handle, which is the link worth copying. `replace`, + // so this is not a history entry; and only once the path a link named has + // been acted on (`onLinkOpened`), so a reload does not download the file a + // second time. + const shownGroupRoute = shownGroup ? groupLinkRoute(shownGroup) : null; + useEffect(() => { + if (shownGroupRoute && !linkPath && route !== shownGroupRoute) { + window.location.replace('#' + shownGroupRoute); + } + }, [route, shownGroupRoute, linkPath]); + const onLinkOpened = useCallback(() => { + if (shownGroupRoute) window.location.replace('#' + shownGroupRoute); + }, [shownGroupRoute]); + let page; // A desktop build with no hub configured cannot do anything at all, so it // asks before showing a sign-in form that could not work. The field comes @@ -1282,17 +1313,23 @@ function App() { }} />`; } else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) { page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`; - } else if (route.startsWith('/group/')) { - const groupId = route.slice(7); - const group = groups.find(g => g.id === groupId); + } else if (groupId) { page = html`<${GroupPage} - groupId=${groupId} group=${group} token=${user.token} + groupId=${groupId} group=${shownGroup} token=${user.token} + openPath=${linkPath} onLinkOpened=${onLinkOpened} username=${user.username} userId=${user.userId} userPrefs=${userPrefs} onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications} onGroupUpdated=${updateGroup} onPresence=${notePresence} onLeft=${handleLeftGroup} onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`; + } else if (groupLink) { + // Not among this account's groups — or not yet known to be. The same words + // whether it does not exist or is someone else's: the list it was looked + // up in is this account's own, so there is nothing else to tell. + page = groupsLoaded + ? html`<div class="page-content"><p class="page-message">${t('group.link_unknown')}</p></div>` + : html`<div class="page-content"><p class="page-message"><span class="spinner"></span></p></div>`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${LazyAdminPage} token=${user.token} role=${user.role} />` @@ -1334,7 +1371,7 @@ function App() { groups=${groups} presence=${presence} indexProgressPct=${indexProgressPct} - route=${route} + route=${groupRoute} menuOpen=${menuOpen} role=${user.role} allowPublicGroups=${allowPublicGroups} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index c12db72..57db475 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -185,8 +185,12 @@ export function LoginPage({ onLogin }) { // Trimmed to match the hub's stored username and every client-side key // derivation (auth_key, bundle_key, recovery_key all fold the username in). await onLogin(name, password); - // Back to the invitation that sent them here, if one is waiting. - navigate(loadPending() ? '/invite' : '/'); + // Back to the invitation that sent them here, if one is waiting. Otherwise + // nowhere: on `#/login` or `#/register` the router sends a signed-in + // person home itself, and anywhere else this form stood in for the page + // the address names — a group, a file in one — which is where they were + // going. Sending everyone home lost every link opened signed out. + if (loadPending()) navigate('/invite'); } catch (err) { if (err.message === 'email_verification_required') { setPendingVerif(true); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 38157b9..978090c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -142,12 +142,16 @@ function FilesPanel({ entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport, + openDirectory, }) { const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); const [filter, setFilter] = useState(''); - const [currentPath, setCurrentPath] = useState(''); + const [currentPath, setCurrentPath] = useState(() => (openDirectory ? openDirectory.dir : '')); + // A link into the group named a folder (group-page.js); a new object each + // time, so the same folder linked twice is opened twice. + useEffect(() => { if (openDirectory) setCurrentPath(openDirectory.dir); }, [openDirectory]); const [refreshing, setRefreshing] = useState(false); // The toolbar pins below the page's own band and tells the column heads how // far down to pin. Its height is not a constant — it wraps to three rows on diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-link.js b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js new file mode 100644 index 0000000..3c9063c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js @@ -0,0 +1,95 @@ +/** + * A group named in the address: `#/name@owner`, optionally followed by a path + * inside it — `#/name@owner/root/folder/file.jpg`. + * + * The same handle `GroupName` shows under every group, so a link reads the way + * the group is labelled. It is resolved against the signed-in account's own + * group list and nothing else: no hub route answers "which group is this + * name", so a name says nothing to someone who is not already a member, and + * nobody can probe for one. The UUID stays the group's identity; a renamed + * group breaks its name links, never its `#/group/<id>` ones. + * + * In the fragment, never the path: what follows `#` is not sent to the server, + * so a group's name and a file's path appear in no hub or proxy log and in no + * Referer. + * + * The owner is everything after the *last* `@`: a username cannot contain one + * (users.py, RegisterRequest), a group name can. Each segment is + * percent-decoded on its own, so a `/` inside a name or a file name travels as + * `%2F` without splitting the path. + * + * No imports, so `test_group_link.py` can execute the module as it is. + */ + +function _decode(segment) { + try { return decodeURIComponent(segment); } catch { return null; } +} + +// `@` is legal in a fragment and the owner is found by the last one, so the +// name's own need no escaping; everything else is escaped as a URI component. +function _encode(segment) { + return encodeURIComponent(segment).replace(/%40/g, '@'); +} + +/** + * `{ name, owner, path }` for a route (the hash without its `#`), or null when + * the route does not name a group. `path` is '' for the group itself. + */ +function parseGroupLink(route) { + if (!route || route[0] !== '/') return null; + const parts = route.slice(1).split('/'); + const head = _decode(parts[0]); + if (!head) return null; + const at = head.lastIndexOf('@'); + if (at <= 0 || at === head.length - 1) return null; + const rest = parts.slice(1).filter(Boolean).map(_decode); + if (rest.some((s) => s === null || s === '.' || s === '..')) return null; + return { name: head.slice(0, at), owner: head.slice(at + 1), path: rest.join('/') }; +} + +/** The route naming `group`, and `path` inside it when one is given. */ +function groupLinkRoute(group, path = '') { + const head = `/${_encode(group.name)}@${_encode(group.owner_username || '')}`; + const tail = path ? '/' + path.split('/').filter(Boolean).map(_encode).join('/') : ''; + return head + tail; +} + +/** + * The group `link` names among `groups` (`/v1/groups/mine` rows), or null. + * + * Case-insensitively on the name, as the hub keeps it unique + * (`uq_groups_owner_name` is on `lower(name)`); the owner as typed first, then + * case-insensitively when that finds exactly one. + */ +function findLinkedGroup(groups, link) { + if (!link) return null; + const name = link.name.toLowerCase(); + const named = (groups || []).filter((g) => (g.name || '').toLowerCase() === name); + const exact = named.find((g) => g.owner_username === link.owner); + if (exact) return exact; + const owner = link.owner.toLowerCase(); + const loose = named.filter((g) => (g.owner_username || '').toLowerCase() === owner); + return loose.length === 1 ? loose[0] : null; +} + +/** + * What `path` names in a group's index: `{ kind: 'file', entry }`, + * `{ kind: 'dir', dir }`, or null. An entry's `path` is its folder, root + * first, and `name` its file name — so a file is matched on both together. + * Folders come from the files under them and from the node's own listing, + * which is the only place an empty one appears. + */ +function resolveLinkedPath(entries, nodeDirs, path) { + if (!path) return null; + const cut = path.lastIndexOf('/'); + const dir = cut < 0 ? '' : path.slice(0, cut); + const name = cut < 0 ? path : path.slice(cut + 1); + const entry = (entries || []).find((e) => (e.path || '') === dir && e.name === name); + if (entry) return { kind: 'file', entry }; + const prefix = path + '/'; + const isDir = (nodeDirs || []).includes(path) + || (entries || []).some((e) => (e.path || '') === path || (e.path || '').startsWith(prefix)); + return isDir ? { kind: 'dir', dir: path } : null; +} + +export { parseGroupLink, groupLinkRoute, findLinkedGroup, resolveLinkedPath }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index fe858b2..a2d6c18 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -5,6 +5,7 @@ import { t } from './i18n.js'; import { Icon } from './icon.js'; import { transfers } from './transfers.js'; import { downloadEntry } from './file-utils.js'; +import { resolveLinkedPath } from './group-link.js'; import { HUB, session, hubFetch, ensureFreshToken, _loadBundleKey, _loadRecoveryKey, _storeBundleKey, @@ -33,7 +34,8 @@ import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js'; */ function GroupPage({ groupId, group, token, username, userId, userPrefs, onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft, - onPlayQueue: parentOnPlayQueue, onStopMusic }) { + onPlayQueue: parentOnPlayQueue, onStopMusic, + openPath = '', onLinkOpened }) { const [status, setStatus] = useState('idle'); // The tab bar pins under the navigation bar and tells the application's own // toolbar how far down to pin (style.css, "Sticky chrome"). @@ -313,12 +315,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, }); }, []); + // Which group `entries` is the index of. The page is not remounted between + // groups, so for one render after a switch `status` and `entries` are still + // the last group's — and a link into the new one must not be looked up there. + const indexGroupRef = useRef(null); + // One place that takes an index from the node and puts it everywhere it has to // go. Deleting a file used to refresh the table and leave the cache alone, so // the search page went on offering a file that no longer existed until the // group was reconnected. const applyIndex = useCallback((indexMsg) => { const fresh = indexMsg.entries || []; + indexGroupRef.current = groupId; setEntries(fresh); if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); if (indexMsg.roots) setNodeRoots(indexMsg.roots); @@ -731,6 +739,28 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, await downloadEntry(transfers, transport, gekRef.current, entry); }, []); + // A link that named a path inside the group (`#/name@owner/root/dir/file`, + // group-link.js): a file is downloaded, and Files opens on its folder either + // way. Acted on once the index is in — before that, nothing can say whether + // the path is a file, a folder or nothing — and then handed back to the + // router, which takes the path out of the address so a reload does not + // download it again. + const [openDirectory, setOpenDirectory] = useState(null); + useEffect(() => { setOpenDirectory(null); }, [groupId]); + useEffect(() => { + if (!openPath || status !== 'connected' || indexGroupRef.current !== groupId) return; + const target = resolveLinkedPath(entries, nodeDirs, openPath); + if (!target) { + setError(t('group.link_path_unknown', { path: openPath })); + } else { + const dir = target.kind === 'file' ? (target.entry.path || '') : target.dir; + setOpenDirectory({ dir }); + chooseTab('files'); + if (target.kind === 'file') downloadFileForModal(target.entry); + } + if (onLinkOpened) onLinkOpened(); + }, [openPath, status, groupId]); + const refreshIndex = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -987,7 +1017,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, </div> ${apps.map(a => tab === a.key && html` - <${a.Component} key=${a.key + '-' + groupId} ...${commonProps} /> + <${a.Component} key=${a.key + '-' + groupId} ...${commonProps} + ...${a.key === 'files' ? { openDirectory } : {}} /> `)} ${tab === 'settings' && html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 5cc9968..8d14dca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -210,6 +210,8 @@ export default { 'group.mkdir_prompt': 'Name des neuen Ordners', 'group.mkdir_offline': 'Nicht mit dem Node verbunden.', 'group.download_offline': 'Keine Verbindung zum Node — der Download kann nicht starten. Die Verbindung wird automatisch wiederhergestellt; versuchen Sie es gleich erneut.', + 'group.link_unknown': "Dieser Link verweist auf eine Gruppe, in der Sie nicht Mitglied sind, oder die nicht mehr so heißt.", + 'group.link_path_unknown': "Unter {path} gibt es in dieser Gruppe nichts — die Datei wurde vielleicht verschoben, umbenannt oder gelöscht.", 'group.download_write_stalled': 'Die Datei wird nicht mehr auf die Festplatte geschrieben ({seconds} s ohne Fortschritt). Der Download wurde abgebrochen statt hängen gelassen; versuchen Sie es erneut.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 5aa53b7..a6cd0b4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -210,6 +210,8 @@ export default { 'group.mkdir_prompt': 'New folder name', 'group.mkdir_offline': 'Not connected to the node.', 'group.download_offline': 'Not connected to the node — the download cannot start. It reconnects on its own; try again in a moment.', + 'group.link_unknown': "This link names a group you are not a member of, or one that no longer goes by that name.", + 'group.link_path_unknown': "Nothing at {path} in this group — the file may have been moved, renamed or deleted.", 'group.download_write_stalled': 'The file stopped being written to disk ({seconds}s with no progress). The download was stopped rather than left hanging; try it again.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 6558a46..e066081 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -208,6 +208,8 @@ export default { 'group.mkdir_prompt': 'Nombre de la nueva carpeta', 'group.mkdir_offline': 'Sin conexión con el nodo.', 'group.download_offline': 'Sin conexión con el nodo — la descarga no puede empezar. Se reconecta sola; inténtelo de nuevo en un momento.', + 'group.link_unknown': "Este enlace nombra un grupo del que no eres miembro, o que ya no se llama así.", + 'group.link_path_unknown': "No hay nada en {path} en este grupo: puede que el archivo se haya movido, renombrado o eliminado.", 'group.download_write_stalled': 'El archivo dejó de escribirse en el disco ({seconds} s sin avance). La descarga se detuvo en lugar de quedarse colgada; inténtelo de nuevo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 5e4c0bf..01ccfcd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -209,6 +209,8 @@ export default { 'group.mkdir_prompt': 'Nom du nouveau dossier', 'group.mkdir_offline': 'Non connecté au nœud.', 'group.download_offline': 'Pas de connexion au node — le téléchargement ne peut pas démarrer. La reconnexion est automatique, réessayez dans un instant.', + 'group.link_unknown': "Ce lien désigne un groupe dont vous n'êtes pas membre, ou qui ne porte plus ce nom.", + 'group.link_path_unknown': "Rien à l'emplacement {path} dans ce groupe — le fichier a peut-être été déplacé, renommé ou supprimé.", 'group.download_write_stalled': 'L\'écriture du fichier sur le disque s\'est arrêtée ({seconds} s sans progression). Le téléchargement a été interrompu plutôt que laissé en suspens ; réessayez.', 'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud', 'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 8772376..71af9f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -209,6 +209,8 @@ export default { 'group.mkdir_prompt': 'Nome della nuova cartella', 'group.mkdir_offline': 'Non connesso al nodo.', 'group.download_offline': 'Nessuna connessione al nodo — il download non può iniziare. La riconnessione è automatica, riprovi tra poco.', + 'group.link_unknown': "Questo link indica un gruppo di cui non sei membro, o che non ha più questo nome.", + 'group.link_path_unknown': "Non c'è nulla in {path} in questo gruppo: il file potrebbe essere stato spostato, rinominato o eliminato.", 'group.download_write_stalled': 'Il file ha smesso di essere scritto su disco ({seconds} s senza progressi). Il download è stato interrotto invece di restare bloccato; riprovi.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index fa34470..4b357c8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -207,6 +207,8 @@ export default { 'group.mkdir_prompt': '新しいフォルダー名', 'group.mkdir_offline': 'ノードに接続していません。', 'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。', + 'group.link_unknown': "このリンクは、あなたがメンバーでないグループ、またはもうその名前ではないグループを指しています。", + 'group.link_path_unknown': "このグループの {path} には何もありません。ファイルが移動、名前変更、または削除された可能性があります。", 'group.download_write_stalled': 'ファイルのディスクへの書き込みが止まりました({seconds} 秒間進みません)。ぶら下がったままにせず中止しました。もう一度お試しください。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index fa0841d..1f4b12c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -210,6 +210,8 @@ export default { 'group.mkdir_prompt': 'Naam van de nieuwe map', 'group.mkdir_offline': 'Niet verbonden met de node.', 'group.download_offline': 'Geen verbinding met de node — de download kan niet starten. Er wordt automatisch opnieuw verbonden; probeer het zo weer.', + 'group.link_unknown': "Deze link verwijst naar een groep waarvan je geen lid bent, of die niet meer zo heet.", + 'group.link_path_unknown': "Er staat niets op {path} in deze groep — het bestand is misschien verplaatst, hernoemd of verwijderd.", 'group.download_write_stalled': 'Het bestand wordt niet meer naar schijf geschreven ({seconds} s zonder voortgang). De download is gestopt in plaats van te blijven hangen; probeer het opnieuw.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 9de29ea..05a3006 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -213,6 +213,8 @@ export default { 'group.mkdir_prompt': 'Nazwa nowego folderu', 'group.mkdir_offline': 'Brak połączenia z węzłem.', 'group.download_offline': 'Brak połączenia z węzłem — pobieranie nie może się rozpocząć. Połączenie wróci samo; proszę spróbować za chwilę.', + 'group.link_unknown': "Ten link wskazuje grupę, do której nie należysz, lub która nie nosi już tej nazwy.", + 'group.link_path_unknown': "W tej grupie nie ma niczego pod {path} — plik mógł zostać przeniesiony, zmieniony lub usunięty.", 'group.download_write_stalled': 'Plik przestał być zapisywany na dysk ({seconds} s bez postępu). Pobieranie zostało przerwane, zamiast wisieć w nieskończoność; proszę spróbować ponownie.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 908a8db..824c69a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -210,6 +210,8 @@ export default { 'group.mkdir_prompt': 'Nome da nova pasta', 'group.mkdir_offline': 'Sem conexão com o nó.', 'group.download_offline': 'Sem conexão com o nó — o download não pode começar. Ele reconecta sozinho; tente de novo em instantes.', + 'group.link_unknown': "Este link aponta para um grupo do qual você não é membro, ou que não tem mais esse nome.", + 'group.link_path_unknown': "Não há nada em {path} neste grupo — o arquivo pode ter sido movido, renomeado ou excluído.", 'group.download_write_stalled': 'O arquivo parou de ser gravado no disco ({seconds}s sem progresso). O download foi interrompido em vez de ficar travado; tente de novo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index d6fe782..d0d3954 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -206,6 +206,8 @@ export default { 'group.mkdir_prompt': '新文件夹名称', 'group.mkdir_offline': '未连接到节点。', 'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。', + 'group.link_unknown': "此链接指向一个您不是其成员的群组,或该群组已不再使用此名称。", + 'group.link_path_unknown': "此群组中 {path} 处没有内容——文件可能已被移动、重命名或删除。", 'group.download_write_stalled': '文件停止写入磁盘({seconds} 秒无进展)。已中止下载而不是让它一直卡住,请重试。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/tests/harness/group_link_probe.py b/packages/meshbay-hub/tests/harness/group_link_probe.py new file mode 100644 index 0000000..2d7c0d7 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_link_probe.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +A group link, `#/name@owner[/path]`, opened in the real application. + +`test_group_link.py` runs `group-link.js` on its own; this is where it meets +the router, the account's group list and the sign-in state. Loads the shipped +`app.js` in a real browser with `fetch` stubbed (no node answers, so a group +page goes as far as "offline"), once per case: + + handle — `#/demo@someowner`: the group page, the address left as it is + uuid — `#/group/<id>`: the same page, the address showing the handle + file — `#/demo@someowner/<path>`: the page, the path kept in the address + until the index can say what it is (no node here: never) + unknown — `#/demo@stranger1`: not among the account's groups + signed_out — `#/demo@someowner/<path>` with no session: the sign-in form, and + the address untouched under it + + group_link_probe.py + +Prints JSON: one object per case. +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8774 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" +FILE = "backup/city_2015/backup/IMG_0001.JPG" +LINKS = { + "handle": "#/demo@someowner", + "uuid": f"#/group/{GROUP}", + "file": f"#/demo@someowner/{FILE}", + "unknown": "#/demo@stranger1", + "signed_out": f"#/demo@someowner/{FILE}", +} +CASES = list(LINKS) + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="app"></div> +<script type="module"> +const CASE = new URLSearchParams(location.search).get('case'); +const LINKS = __LINKS__; +const realFetch = window.fetch.bind(window); +const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) }); +const calls = []; +const json = (body, status = 200) => ({ + ok: status < 400, status, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body), +}); +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push(u); + if (u.includes('/v1/users/me/preferences')) return json({}); + if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' }); + if (u.includes('/v1/groups/mine')) return json({ groups: [ + { id: '__GROUP__', name: 'demo', owner_username: 'someowner', visibility: 'private', + created_at: '2026-01-01T00:00:00+00:00', description: '' }, + { id: 'other-group', name: 'demo', owner_username: 'otherowner', visibility: 'private', + created_at: '2026-01-01T00:00:00+00:00', description: '' }, + ] }); + if (u.includes('/nodes')) return json({ nodes: [] }); + return json({}); +}; +if (CASE === 'signed_out') { + localStorage.removeItem('mb_auth'); +} else { + localStorage.setItem('mb_auth', JSON.stringify({ + username: 'member-account', userId: 'u-1', token: 'tok', refreshToken: 'ref', + role: 'user' })); +} +sessionStorage.clear(); +history.replaceState(null, '', '/?case=' + CASE + LINKS[CASE]); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + const out = { case: CASE }; + try { + await import('/app.js'); + await wait(2000); + out.hash = decodeURI(location.hash); + out.history_length = history.length; + out.group_page = Boolean(document.querySelector('.group-header')); + out.group_title = (document.querySelector('.group-header h2') || {}).innerText || ''; + out.active_sidebar = [...document.querySelectorAll('.sidebar-group.active')] + .map((a) => a.getAttribute('href')); + out.message = (document.querySelector('main .page-message') || {}).innerText || ''; + out.spinner = Boolean(document.querySelector('main .page-message .spinner')); + out.login_form = Boolean(document.querySelector('input[type=password]')); + out.hub_calls = calls.map((c) => c.replace(/^https?:\/\/[^/]+/, '')); + } catch (e) { + out.error = String(e && e.stack || e); + } + post(out); +})(); +</script></body></html> +""".replace("__GROUP__", GROUP).replace("__LINKS__", json.dumps(LINKS)) + + +class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + return + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else ( + "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream") + self._send(asset.read_bytes(), ctype) + + +def _run(case: str) -> dict | None: + before = len(RECORDS) + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", f"http://127.0.0.1:{PORT}/?case={case}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if len(RECORDS) > before: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return RECORDS[before] if len(RECORDS) > before else None + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + results = [_run(case) for case in CASES] + if not all(results): + print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) + return 1 + print(json.dumps(results, indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_group_link.py b/packages/meshbay-hub/tests/test_group_link.py new file mode 100644 index 0000000..e71df20 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_link.py @@ -0,0 +1,170 @@ +""" +A group named in the address: `#/name@owner[/path]`. + +The handle under every group's name is also a link to it, and a path after it +names a folder to open or a file to download. `group-link.js` parses it, +builds it, finds the group among the account's own and the entry in the +group's index; the module is executed whole, as `test_search_source_merge.py` +does with `source-merge.js`, so these rules are the ones the page runs. + +Also held here, at source level: the sign-in form no longer sends everyone +home, which is what made any link opened signed out land on the home page. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SRC = STATIC / "group-link.js" + +IMPORT = re.compile(r"^\s*import\b", re.M) +EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M) + +needs_node = pytest.mark.skipif( + shutil.which("node") is None or not SRC.exists(), + reason="node or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def module_source(): + text = SRC.read_text(encoding="utf-8") + assert not IMPORT.search(text), ( + "group-link.js has gained an import; this test runs it standalone") + stripped, n = EXPORT.subn("", text) + assert n == 1 + return stripped + + +def _run(tmp_path, module_source, expr): + script = tmp_path / "case.js" + script.write_text(f"{module_source}\nconsole.log(JSON.stringify({expr}));\n", + encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, text=True, + encoding="utf-8", timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +GROUPS = [ + {"id": "g1", "name": "demo", "owner_username": "someowner"}, + {"id": "g2", "name": "demo", "owner_username": "otherowner"}, + {"id": "g3", "name": "trips@home", "owner_username": "someowner"}, + {"id": "g4", "name": "a/b c", "owner_username": "someowner"}, +] + +ENTRIES = [ + {"path": "backup/city_2015/backup", "name": "IMG_0001.JPG"}, + {"path": "backup/city_2015", "name": "notes.txt"}, + {"path": "music", "name": "track 01.flac"}, +] + + +@needs_node +@pytest.mark.parametrize("route, expected", [ + ("/demo@someowner", {"name": "demo", "owner": "someowner", "path": ""}), + ("/demo@someowner/backup/city_2015/backup/IMG_0001.JPG", + {"name": "demo", "owner": "someowner", + "path": "backup/city_2015/backup/IMG_0001.JPG"}), + # The owner is after the last `@`: a group name may hold one, a username not. + ("/trips@home@someowner", {"name": "trips@home", "owner": "someowner", "path": ""}), + # Each segment decoded on its own: an escaped `/` stays inside its segment. + ("/a%2Fb%20c@someowner/music/track%2001.flac", + {"name": "a/b c", "owner": "someowner", "path": "music/track 01.flac"}), + ("/demo@someowner/music/", {"name": "demo", "owner": "someowner", "path": "music"}), + # Every other route, and anything that is not a well-formed handle. + ("/group/0f8fad5b-d9cb-469f-a165-70867728950e", None), + ("/login", None), ("/", None), ("", None), + ("/@someowner", None), ("/demo@", None), + ("/demo@someowner/%E0%A4%A", None), + ("/demo@someowner/music/../../etc", None), +]) +def test_parse(tmp_path, module_source, route, expected): + assert _run(tmp_path, module_source, f"parseGroupLink({json.dumps(route)})") == expected + + +@needs_node +@pytest.mark.parametrize("group, path", [ + (GROUPS[0], ""), + (GROUPS[0], "backup/city_2015/backup/IMG_0001.JPG"), + (GROUPS[2], ""), + (GROUPS[3], "music/track 01.flac"), + ({"name": "Été à la mer", "owner_username": "someowner"}, "photos/plage #1.jpg"), +]) +def test_built_routes_parse_back(tmp_path, module_source, group, path): + out = _run(tmp_path, module_source, + f"(() => {{ const r = groupLinkRoute({json.dumps(group)}, {json.dumps(path)});" + f" return [r, parseGroupLink(r)]; }})()") + route, parsed = out + assert parsed == {"name": group["name"], "owner": group["owner_username"], "path": path} + # Nothing that ends or splits a fragment is left bare. + assert not re.search(r"[#?\s%](?![0-9A-F]{2})", route) + + +@needs_node +def test_a_plain_handle_stays_readable(tmp_path, module_source): + route = _run(tmp_path, module_source, + f"groupLinkRoute({json.dumps(GROUPS[0])}, 'backup/city_2015/IMG_1.JPG')") + assert route == "/demo@someowner/backup/city_2015/IMG_1.JPG" + + +@needs_node +@pytest.mark.parametrize("link, expected", [ + ({"name": "demo", "owner": "someowner"}, "g1"), + ({"name": "demo", "owner": "otherowner"}, "g2"), + # The hub keeps names unique on lower(name). + ({"name": "DEMO", "owner": "someowner"}, "g1"), + ({"name": "demo", "owner": "SomeOwner"}, "g1"), + # Not among the account's groups: nothing, and nothing asked of the hub. + ({"name": "demo", "owner": "stranger1"}, None), + ({"name": "secret", "owner": "someowner"}, None), +]) +def test_find_among_own_groups(tmp_path, module_source, link, expected): + out = _run(tmp_path, module_source, + f"(findLinkedGroup({json.dumps(GROUPS)}, {json.dumps(link)}) || {{}}).id || null") + assert out == expected + + +@needs_node +@pytest.mark.parametrize("path, expected", [ + ("backup/city_2015/backup/IMG_0001.JPG", + {"kind": "file", "entry": ENTRIES[0]}), + ("backup/city_2015/notes.txt", {"kind": "file", "entry": ENTRIES[1]}), + ("backup/city_2015", {"kind": "dir", "dir": "backup/city_2015"}), + ("backup", {"kind": "dir", "dir": "backup"}), + # An empty folder exists only in the node's own listing. + ("backup/empty", {"kind": "dir", "dir": "backup/empty"}), + # A prefix of a folder name is not that folder. + ("backup/city", None), + ("backup/city_2015/backup/img_0001.jpg", None), + ("", None), +]) +def test_resolve_in_index(tmp_path, module_source, path, expected): + out = _run(tmp_path, module_source, + f"resolveLinkedPath({json.dumps(ENTRIES)}, ['backup/empty'], {json.dumps(path)})") + assert out == expected + + +def test_signing_in_keeps_the_page_the_address_names(): + """A group or file link opened signed out shows the sign-in form in its + place; signing in must leave the address alone, not send everyone home.""" + source = (STATIC / "auth-page.js").read_text(encoding="utf-8") + body = source[source.index("export function LoginPage"):] + body = body[:body.index("\n}\n")] + assert "navigate('/')" not in body and 'navigate("/")' not in body + assert "if (loadPending()) navigate('/invite');" in body + + +def test_the_router_resolves_a_handle_and_shows_it(): + source = (STATIC / "app.js").read_text(encoding="utf-8") + body = source[source.index("\nfunction App() {"):] + assert "parseGroupLink(route)" in body + assert "findLinkedGroup(groups, groupLink)" in body + # Rewritten with `replace`: showing the handle is not a history entry. + assert "window.location.replace('#' + shownGroupRoute)" in body + # The sidebar highlights the group whichever form opened it. + assert re.search(r"<\$\{Sidebar\}[\s\S]*?route=\$\{groupRoute\}", body) diff --git a/packages/meshbay-hub/tests/test_group_link_flow.py b/packages/meshbay-hub/tests/test_group_link_flow.py new file mode 100644 index 0000000..eb6a353 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_link_flow.py @@ -0,0 +1,67 @@ +""" +A group link, opened in the real application (harness/group_link_probe.py). + +`test_group_link.py` holds the parsing and the lookups; this is where they meet +the router. A handle opens the group's page, and an id opens it with the handle +in the address; a path waits in the address for the index to say what it is; a +handle the account does not know is said to be unknown without the hub ever +being asked about it; and signed out, the sign-in form stands in for the page +with the address left alone, which is what lets signing in land there. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "group_link_probe.py" +GROUP_HREF = "#/group/0f8fad5b-d9cb-469f-a165-70867728950e" +FILE_LINK = "#/demo@someowner/backup/city_2015/backup/IMG_0001.JPG" + + +@pytest.fixture(scope="module") +def cases(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=240) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = {c["case"]: c for c in json.loads(proc.stdout)} + for c in out.values(): + assert "error" not in c, c["error"] + return out + + +@pytest.mark.parametrize("case", ["handle", "uuid", "file"]) +def test_a_handle_or_an_id_opens_the_group(cases, case): + c = cases[case] + assert c["group_page"] and c["group_title"] == "demo@someowner" + # Of two groups called "demo", the one whose owner the handle names. + assert c["active_sidebar"] == [GROUP_HREF] + + +@pytest.mark.parametrize("case", ["handle", "uuid"]) +def test_the_address_shows_the_handle_without_a_history_entry(cases, case): + assert cases[case]["hash"] == "#/demo@someowner" + assert cases[case]["history_length"] == 1 + + +def test_a_path_stays_in_the_address_until_it_has_been_acted_on(cases): + assert cases["file"]["hash"] == FILE_LINK + + +def test_an_unknown_handle_is_said_so_without_asking_the_hub(cases): + c = cases["unknown"] + assert not c["group_page"] and c["message"] and not c["spinner"] + assert c["hash"] == "#/demo@stranger1" + for case in cases.values(): + assert not any("stranger1" in u or "@" in u for u in case["hub_calls"]) + + +def test_signed_out_the_form_stands_in_for_the_page(cases): + c = cases["signed_out"] + assert c["login_form"] and not c["group_page"] + assert c["hash"] == FILE_LINK |