aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-name.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css20
-rw-r--r--packages/meshbay-hub/tests/test_group_name_migration.py64
-rw-r--r--packages/meshbay-hub/tests/test_group_name_unique.py78
12 files changed, 320 insertions, 30 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 4141c79..ab6fa07 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -301,12 +301,18 @@ async def admin_list_groups(
total = (await db.execute(select(func.count()).select_from(Group))).scalar_one()
+ owner_ids = {g.admin_id for g, _ in rows}
+ owner_by_id = dict((await db.execute(
+ select(User.id, User.username).where(User.id.in_(owner_ids)))).all()) \
+ if owner_ids else {}
+
return {
"groups": [
{
"id": g.id,
"name": g.name,
"admin_id": g.admin_id,
+ "owner_username": owner_by_id.get(g.admin_id),
"visibility": g.visibility,
"description": g.description or "",
"status": g.status,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 91aa9c4..49902de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from datetime import datetime, timezone
from sqlalchemy import func, or_, select, update
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import hub_settings
@@ -31,8 +32,9 @@ async def my_groups(
from meshbay_hub.api.revocation import get_online_nodes_for_group
result = await db.execute(
- select(Group)
+ select(Group, User.username)
.join(GroupMember, Group.id == GroupMember.group_id)
+ .join(User, User.id == Group.admin_id) # the owner, for the @handle
.where(GroupMember.user_id == current_user.id, Group.status == "active",
# A group nobody hosts yet is the owner's business alone. Someone
# added to it before a node exists would see a name they cannot
@@ -40,7 +42,9 @@ async def my_groups(
or_(Group.hosted_at.is_not(None), Group.admin_id == current_user.id))
.order_by(Group.last_activity_at.desc())
)
- groups = result.scalars().all()
+ rows = result.all()
+ groups = [g for g, _ in rows]
+ owner_by_id = {g.id: owner for g, owner in rows}
muted_rows = await db.execute(
select(GroupMember.group_id, GroupMember.muted)
.where(GroupMember.user_id == current_user.id))
@@ -50,6 +54,7 @@ async def my_groups(
{
"id": g.id,
"name": g.name,
+ "owner_username": owner_by_id.get(g.id),
"visibility": g.visibility,
"muted": muted_map.get(g.id, False),
"join_policy": g.join_policy,
@@ -147,22 +152,24 @@ async def list_public_groups(
# Unhosted groups are absent from the directory: until a node announces it,
# a group has no files, no key and nothing to connect to, so listing it only
# produces a dead end. Its owner still sees it in /mine while they set it up.
- query = select(Group).where(Group.visibility == "public",
- Group.status == "active",
- Group.hosted_at.is_not(None))
+ query = (select(Group, User.username)
+ .join(User, User.id == Group.admin_id)
+ .where(Group.visibility == "public",
+ Group.status == "active",
+ Group.hosted_at.is_not(None)))
if q:
query = query.where(Group.name.ilike(f"%{q}%"))
result = await db.execute(
query.order_by(Group.created_at.desc()).limit(limit).offset(offset)
)
- local = result.scalars().all()
+ local = result.all()
groups = [
{
"id": g.id, "name": g.name, "join_policy": g.join_policy,
- "description": g.description or "",
+ "owner_username": owner, "description": g.description or "",
"created_at": g.created_at.isoformat(), "source": "local",
}
- for g in local
+ for g, owner in local
]
if include_federated:
@@ -306,7 +313,9 @@ async def join_group(
db.add(IPLog(user_id=current_user.id, event="group_join",
ip_address=client_ip(request), detail=group.name))
await db.commit()
- return {"status": "joined", "group_id": group_id, "name": group.name}
+ owner = await db.scalar(select(User.username).where(User.id == group.admin_id))
+ return {"status": "joined", "group_id": group_id, "name": group.name,
+ "owner_username": owner}
class GroupCreateRequest(BaseModel):
@@ -386,6 +395,25 @@ async def create_group(
status_code=422,
detail="A private group is invite-only. Make it public if you want "
"anyone to be able to join.")
+
+ name = body.name.strip()
+ if not name:
+ raise HTTPException(status_code=422, detail="A group needs a name.")
+ # One name per owner, case-insensitively. Two *different* owners may each
+ # have a "photos" — that is why the check is scoped to `admin_id` and why
+ # the group's real identity stays its UUID. The DB has a unique index too
+ # (uq_groups_owner_name); this is the friendly error, that is the race
+ # backstop below.
+ clash = await db.scalar(
+ select(Group.id).where(
+ Group.admin_id == current_user.id,
+ func.lower(Group.name) == name.lower()))
+ if clash:
+ raise HTTPException(
+ status_code=409,
+ detail=f"You already have a group called “{name}”. Pick another name "
+ "— names only have to be unique among your own groups.")
+
if body.visibility == "public":
if not await hub_settings.public_groups_allowed(db):
# Instance policy, set by a hub admin in the panel. Absolute — staff
@@ -399,7 +427,7 @@ async def create_group(
desc = (body.description or "")[:512] if body.description else None
group = Group(
- name=body.name,
+ name=name,
admin_id=current_user.id,
visibility=body.visibility,
join_policy=body.join_policy,
@@ -410,10 +438,18 @@ async def create_group(
db.add(GroupMember(group_id=group.id, user_id=current_user.id))
db.add(IPLog(user_id=current_user.id, event="group_create",
- ip_address=client_ip(request), detail=body.name))
- await db.commit()
+ ip_address=client_ip(request), detail=name))
+ try:
+ await db.commit()
+ except IntegrityError:
+ # Two creates for the same owner+name raced past the check above.
+ await db.rollback()
+ raise HTTPException(
+ status_code=409,
+ detail=f"You already have a group called “{name}”.")
await db.refresh(group)
- return {"group_id": group.id, "name": group.name}
+ return {"group_id": group.id, "name": group.name,
+ "owner_username": current_user.username}
@router.delete("/{group_id}/members/{username}")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py
new file mode 100644
index 0000000..6fc5b73
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py
@@ -0,0 +1,45 @@
+"""group_name_unique_per_owner
+
+Revision ID: c3d4e5f6a7b8
+Revises: b1c2d3e4f5a6
+Create Date: 2026-08-28 14:00:00.000000
+
+One group name per owner, case-insensitively. The group's identity is still
+its UUID — this only makes `name@owner` a dependable handle.
+
+Pre-flight: abort if the data already violates it, with the offending
+(admin_id, name) pairs listed, rather than silently renaming anyone's group.
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'c3d4e5f6a7b8'
+down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ dupes = bind.execute(sa.text(
+ "SELECT admin_id, lower(name) AS n, count(*) AS c "
+ "FROM groups GROUP BY admin_id, lower(name) HAVING count(*) > 1"
+ )).fetchall()
+ if dupes:
+ listed = ", ".join(f"{r.admin_id[:8]}/{r.n!r}×{r.c}" for r in dupes)
+ raise RuntimeError(
+ "Cannot add uq_groups_owner_name: an owner already has two groups "
+ f"with the same name (case-insensitively): {listed}. "
+ "Rename or delete one of each pair, then re-run the migration."
+ )
+ op.create_index(
+ "uq_groups_owner_name", "groups",
+ ["admin_id", sa.text("lower(name)")], unique=True,
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("uq_groups_owner_name", table_name="groups")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 0c337c1..dbabfc2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Index, Integer,
- String, Text, UniqueConstraint,
+ String, Text, UniqueConstraint, text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -109,7 +109,14 @@ class Group(Base):
members: Mapped[list["GroupMember"]] = relationship(back_populates="group")
- __table_args__ = (Index("ix_groups_name", "name"),)
+ __table_args__ = (
+ Index("ix_groups_name", "name"),
+ # One name per owner, case-insensitively. The group's identity stays its
+ # UUID; this is what makes `name@owner` a handle a human can rely on
+ # (two different owners may still both have a "photos"). Enforced in the
+ # DB so a race cannot slip a second one past the check in create_group.
+ Index("uq_groups_owner_name", "admin_id", text("lower(name)"), unique=True),
+ )
class GroupMember(Base):
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 9164a4d..18f34fc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -16,6 +16,7 @@ import {
refreshAccessToken,
} from './hub-client.js';
import { GroupPage } from './group-page.js';
+import { GroupName } from './group-name.js';
import { APPS } from './apps.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -352,7 +353,9 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha
<span class="presence presence-${state}"
title="${label}"
aria-label="${label}"></span>
- <span class="sidebar-item-name">${g.name}</span>
+ <span class="sidebar-item-name">
+ <${GroupName} name=${g.name} owner=${g.owner_username} />
+ </span>
</a>
`;
})
@@ -699,13 +702,11 @@ function ExplorePage({ token, myGroupIds, allowPublicGroups = true }) {
${groups.map(g => html`
<div key=${g.id} class="group-card">
<a href="#/group/${g.id}" style="text-decoration:none;color:inherit">
- <h3>${g.name}</h3>
+ <h3><${GroupName} name=${g.name}
+ owner=${g.source && g.source !== 'local' ? g.source : g.owner_username} /></h3>
${g.description && html`<p class="group-card-desc">${g.description}</p>`}
</a>
<span class="badge">${g.join_policy}</span>
- ${g.source && g.source !== 'local' && html`
- ${' '}<span class="badge">${g.source}</span>
- `}
${' '}
${isMember(g.id)
? html`<span class="badge">${t('explore.member')}</span>`
@@ -1331,7 +1332,7 @@ function SearchPage() {
if (e.name.toLowerCase().includes(term) ||
(e.path && e.path.toLowerCase().includes(term))) {
hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName,
- syncedAt: idx.cachedAt });
+ groupOwner: idx.groupOwner, syncedAt: idx.cachedAt });
}
}
}
@@ -1376,7 +1377,9 @@ function SearchPage() {
</td>
<td class="file-size">${formatSize(r.size)}</td>
<td>
- <a href="#/group/${r.groupId}" class="badge">${r.groupName || r.groupId.slice(0, 8)}</a>
+ <a href="#/group/${r.groupId}" class="badge">${r.groupName
+ ? html`<${GroupName} name=${r.groupName} owner=${r.groupOwner} inline=${true} />`
+ : r.groupId.slice(0, 8)}</a>
${r.syncedAt && html`
<div class="search-synced">
${t('search.synced', { ago: formatAgo(r.syncedAt) })}
@@ -2175,7 +2178,7 @@ function AdminPage({ token, role }) {
${groups.length === 0 && html`<tr><td colspan="6" class="admin-empty">${t('admin.no_groups')}</td></tr>`}
${groups.map(g => html`
<tr key=${g.id}>
- <td>${g.name}</td>
+ <td><${GroupName} name=${g.name} owner=${g.owner_username} /></td>
<td><span class="badge">${g.visibility}</span></td>
<td>${g.member_count}</td>
<td><span class="badge ${g.status === 'active' ? 'badge-ok' : g.status === 'suspended' ? 'badge-err' : ''}">${g.status}</span></td>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js
new file mode 100644
index 0000000..af8879c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js
@@ -0,0 +1,22 @@
+import { html } from './vendor/htm-preact.js';
+
+/**
+ * A group's name with the `@owner` handle under it.
+ *
+ * Group names are unique only per owner account (the hub enforces that), so the
+ * `@handle` is what tells two groups called "photos" apart. `owner` is the
+ * owner's username for a local group, or the source hub for a federated one
+ * (decision 5 in ~/next/groupnames.md); the caller decides which.
+ *
+ * `inline` renders "name@owner" on one line, for places that cannot take a
+ * block — a badge, a `confirm()` string built elsewhere.
+ */
+export function GroupName({ name, owner, inline = false }) {
+ if (inline) return owner ? `${name}@${owner}` : name;
+ return html`
+ <span class="gn">
+ <span class="gn-name">${name}</span>
+ ${owner && html`<span class="gn-owner">@${owner}</span>`}
+ </span>
+ `;
+}
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 470f169..4ebe5a7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -9,6 +9,7 @@ import {
HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey,
} from './hub-client.js';
import { visibleApps } from './apps.js';
+import { GroupName } from './group-name.js';
import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
import { MusicPlayerBar } from './music-player.js';
@@ -160,7 +161,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setEntries(fresh);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
- cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
+ cacheGroupIndex(groupId, group ? group.name : groupId,
+ group ? group.owner_username : null, fresh);
}, [groupId, group]);
// additions/deletions/updates (daemon.py _broadcast_index_change, once
@@ -181,7 +183,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const keptIds = new Set(updated.map((e) => e.id));
const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id));
const fresh = updated.concat(additions);
- cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
+ cacheGroupIndex(groupId, group ? group.name : groupId,
+ group ? group.owner_username : null, fresh);
return fresh;
});
}, [groupId, group]);
@@ -493,7 +496,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
<div class="group-header">
<div>
<h2 style="margin-bottom:${group && group.description ? '4px' : '0'}">
- ${group ? group.name : t('group.default_name')}
+ ${group
+ ? html`<${GroupName} name=${group.name} owner=${group.owner_username} />`
+ : t('group.default_name')}
</h2>
${editingDesc
? html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 60b0184..59d1456 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1244,7 +1244,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${isOwner
? html`
<button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
+ if (!confirm(t('group.delete_group_confirm', {
+ name: group.owner_username ? `${group.name}@${group.owner_username}` : group.name,
+ }))) return;
try {
// Node detach first (reversible), then hub delete (irreversible)
if (nodeDetected && nodeGroupName) {
@@ -1263,7 +1265,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
`
: html`
<button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
+ if (!confirm(t('group.leave_confirm', {
+ name: group.owner_username ? `${group.name}@${group.owner_username}` : group.name,
+ }))) return;
try {
await hubFetch('/v1/groups/' + groupId + '/leave',
{ method: 'POST', token });
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index aaf7ddb..176d22b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -35,12 +35,12 @@ function openDB() {
});
}
-async function cacheGroupIndex(groupId, groupName, entries) {
+async function cacheGroupIndex(groupId, groupName, groupOwner, entries) {
try {
const db = await openDB();
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).put({
- groupId, groupName, entries, cachedAt: Date.now(),
+ groupId, groupName, groupOwner, entries, cachedAt: Date.now(),
});
await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; });
db.close();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index cd9882e..810f06b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2136,9 +2136,29 @@ a.transfer-name {
.sidebar-item-name {
overflow: hidden;
+ min-width: 0;
+}
+
+/* Group name + its @owner handle, stacked. Names are unique only per owner
+ account, so the handle is what tells two "photos" groups apart. */
+.gn { display: block; min-width: 0; }
+.gn-name {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.gn-owner {
+ display: block;
+ overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ font-size: 0.8em;
+ font-weight: 400;
+ color: var(--text-dim);
}
+h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
+.sidebar-item.active .gn-owner { color: inherit; opacity: 0.75; }
/* ── Chat: paging and orientation ─────────────────────────────────────────── */
diff --git a/packages/meshbay-hub/tests/test_group_name_migration.py b/packages/meshbay-hub/tests/test_group_name_migration.py
new file mode 100644
index 0000000..9813e04
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_group_name_migration.py
@@ -0,0 +1,64 @@
+"""
+The uq_groups_owner_name migration refuses to run over data that already
+violates it, naming the offending pairs — it must never silently rename a
+group.
+"""
+
+import sqlite3
+from pathlib import Path
+
+import pytest
+from alembic import command
+from alembic.config import Config
+
+HUB = Path(__file__).resolve().parents[1]
+BEFORE = "b1c2d3e4f5a6"
+AFTER = "c3d4e5f6a7b8"
+
+_USER = ("INSERT INTO users (id, username, email, pw_hash, pw_salt, hub_id) "
+ "VALUES ('u1', 'alice', 'a@x', x'00', x'00', 'h')")
+_GROUP = ("INSERT INTO groups (id, name, admin_id, visibility, join_policy, status) "
+ "VALUES (?, ?, 'u1', 'private', 'invite', 'active')")
+
+
+@pytest.fixture
+def at_before(tmp_path, monkeypatch):
+ """A hub DB migrated up to the revision just before uq_groups_owner_name."""
+ db = tmp_path / "hub.db"
+ monkeypatch.setenv("MESHBAY_DATABASE_URL", f"sqlite+aiosqlite:///{db}")
+ cfg = Config(str(HUB / "alembic.ini"))
+ command.upgrade(cfg, BEFORE)
+ return db, cfg
+
+
+def test_migration_aborts_on_a_pre_existing_duplicate(at_before):
+ db, cfg = at_before
+ con = sqlite3.connect(db)
+ con.execute(_USER)
+ con.execute(_GROUP, ("g1", "Photos"))
+ con.execute(_GROUP, ("g2", "photos")) # same owner, same name bar case
+ con.commit()
+ con.close()
+
+ with pytest.raises(Exception) as exc:
+ command.upgrade(cfg, AFTER)
+ assert "uq_groups_owner_name" in str(exc.value)
+ assert "photos" in str(exc.value).lower()
+
+
+def test_migration_runs_when_data_is_clean(at_before):
+ db, cfg = at_before
+ con = sqlite3.connect(db)
+ con.execute(_USER)
+ con.execute(_GROUP, ("g1", "Photos"))
+ con.execute(_GROUP, ("g2", "Videos"))
+ con.commit()
+ con.close()
+
+ command.upgrade(cfg, AFTER)
+
+ con = sqlite3.connect(db)
+ idx = [r[0] for r in con.execute(
+ "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='groups'")]
+ con.close()
+ assert "uq_groups_owner_name" in idx
diff --git a/packages/meshbay-hub/tests/test_group_name_unique.py b/packages/meshbay-hub/tests/test_group_name_unique.py
new file mode 100644
index 0000000..2bc109c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_group_name_unique.py
@@ -0,0 +1,78 @@
+"""
+A group name is unique per owner account, case-insensitively — the group's
+identity stays its UUID, this only makes `name@owner` a dependable handle.
+"""
+
+import base64
+import hashlib
+
+import pytest
+
+
+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 _create(client, headers, name):
+ return await client.post("/v1/groups", json={"name": name}, headers=headers)
+
+
+@pytest.mark.asyncio
+async def test_same_owner_same_name_is_refused(client):
+ alice = await _user(client, "alice")
+ r1 = await _create(client, alice, "photos")
+ assert r1.status_code == 201
+ assert r1.json()["owner_username"] == "alice"
+
+ r2 = await _create(client, alice, "photos")
+ assert r2.status_code == 409
+ assert "photos" in r2.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_same_owner_different_case_is_refused(client):
+ alice = await _user(client, "alice")
+ assert (await _create(client, alice, "Photos")).status_code == 201
+ assert (await _create(client, alice, " photos ")).status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_two_owners_may_share_a_name(client):
+ alice = await _user(client, "alice")
+ bob = await _user(client, "bob")
+ assert (await _create(client, alice, "photos")).status_code == 201
+ assert (await _create(client, bob, "photos")).status_code == 201
+
+
+@pytest.mark.asyncio
+async def test_name_is_trimmed_on_create(client):
+ alice = await _user(client, "alice")
+ r = await _create(client, alice, " spaced out ")
+ assert r.status_code == 201
+ assert r.json()["name"] == "spaced out"
+
+
+@pytest.mark.asyncio
+async def test_blank_name_is_refused(client):
+ alice = await _user(client, "alice")
+ assert (await _create(client, alice, " ")).status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_owner_username_is_reported_in_listings(client):
+ alice = await _user(client, "alice")
+ await _create(client, alice, "photos")
+
+ mine = (await client.get("/v1/groups/mine", headers=alice)).json()["groups"]
+ assert mine and mine[0]["owner_username"] == "alice"