aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_group_purge.py2
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py4
-rw-r--r--packages/meshbay-hub/tests/test_invite_links.py45
-rw-r--r--packages/meshbay-hub/tests/test_site_basics.py81
-rw-r--r--packages/meshbay-hub/tests/test_welcome_layout_measured.py48
5 files changed, 150 insertions, 30 deletions
diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py
index 9c6a664..40d2d54 100644
--- a/packages/meshbay-hub/tests/test_group_purge.py
+++ b/packages/meshbay-hub/tests/test_group_purge.py
@@ -84,7 +84,7 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name
ip_address="192.0.2.1"))
owner_id = (await db.execute(select(Group.admin_id).where(Group.id == gid))).scalar_one()
db.add(GroupInviteLink(group_id=gid, created_by=owner_id, ticket_hash=gid[:8] * 8,
- email_hash="1" * 64, email_masked="m***@e***.com",
+ email_masked="m***@e***.com",
expires_at=datetime.now(UTC) + timedelta(days=1),
redeemed_by=member_id, redeemed_at=datetime.now(UTC)))
await db.commit()
diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py
index 2223ad8..313e224 100644
--- a/packages/meshbay-hub/tests/test_invite_link_client.py
+++ b/packages/meshbay-hub/tests/test_invite_link_client.py
@@ -188,7 +188,9 @@ def test_the_members_tab_sends_the_code_only_for_the_mail():
sends = [m.start() for m in re.finditer(r"code: node\.code", settings)]
assert len(sends) == 1
before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]]
- assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks"
+ assert "mailIt ?" in before, "the code reaches the hub only when the box asks"
+ assert "const mailIt = inviteByEmail && Boolean(email);" in settings, (
+ "and the box asks only when there is an address to mail")
def test_signing_out_forgets_the_invitation():
diff --git a/packages/meshbay-hub/tests/test_invite_links.py b/packages/meshbay-hub/tests/test_invite_links.py
index 461cf8a..2217cfe 100644
--- a/packages/meshbay-hub/tests/test_invite_links.py
+++ b/packages/meshbay-hub/tests/test_invite_links.py
@@ -63,22 +63,16 @@ def sent(monkeypatch):
# ── Who gets in ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
-async def test_the_addressed_account_joins_and_nobody_else(client, db_session):
+async def test_whoever_opens_it_first_joins_and_nobody_after(client, db_session):
+ # A link travels by any messaging app, so the address the owner typed binds
+ # nothing: an account registered with another one redeems it.
owner = await _account(client, "link_owner")
gid = await _group(client, owner)
r = await _link(client, owner, gid, email="Invitee@Example.test")
assert r.status_code == 201, r.text
ticket = r.json()["ticket"]
- mallory = await _account(client, "link_mallory")
- for route in ("preview", "redeem"):
- r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket},
- headers=mallory["h"])
- assert r.status_code == 403 and r.json()["detail"] == "invite_other_account"
- assert "invitee" not in r.text.lower(), "the refusal must not name the address"
-
- # Registered with the address the owner typed, case aside.
- invitee = await _account(client, "link_invitee", email="invitee@example.test")
+ invitee = await _account(client, "link_invitee", email="elsewhere@example.test")
r = await client.post("/v1/invite-links/preview", json={"ticket": ticket},
headers=invitee["h"])
assert r.status_code == 200, r.text
@@ -102,11 +96,30 @@ async def test_the_addressed_account_joins_and_nobody_else(client, db_session):
GroupMember.group_id == gid))).scalars().all()
assert len(rows) == 2
- # And the one who comes after, even with the right address, gets nothing:
- # a twin account cannot exist (addresses are unique), so try the other.
- r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket},
- headers=mallory["h"])
- assert r.status_code == 404
+ # Whoever comes after, even with the address the owner typed, gets nothing.
+ late = await _account(client, "link_late", email="invitee@example.test")
+ for route in ("preview", "redeem"):
+ r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket},
+ headers=late["h"])
+ assert r.status_code == 404 and r.json()["detail"] == "invite_not_valid"
+
+
+@pytest.mark.asyncio
+async def test_a_link_needs_no_address_unless_it_is_mailed(client, db_session, sent):
+ owner = await _account(client, "noaddr_owner")
+ gid = await _group(client, owner)
+ r = await _link(client, owner, gid, email="")
+ assert r.status_code == 201, r.text
+ assert r.json()["email_status"] == "not_requested"
+ row = (await db_session.execute(select(GroupInviteLink))).scalar_one()
+ assert row.email_masked is None
+ listed = (await client.get(f"/v1/groups/{gid}/invite-links",
+ headers=owner["h"])).json()["links"]
+ assert [link["email"] for link in listed] == [""]
+
+ r = await _link(client, owner, gid, email="", send_email=True,
+ node_pk=NODE_PK, code=CODE)
+ assert r.status_code == 422 and sent == []
@pytest.mark.asyncio
@@ -115,7 +128,7 @@ async def test_a_ticket_is_stored_only_as_a_hash(client, db_session):
gid = await _group(client, owner)
ticket = (await _link(client, owner, gid)).json()["ticket"]
row = (await db_session.execute(select(GroupInviteLink))).scalar_one()
- assert ticket not in (row.ticket_hash, row.email_masked, row.email_hash)
+ assert ticket not in (row.ticket_hash, row.email_masked)
assert row.ticket_hash == invite_links.ticket_hash(ticket)
assert "invitee@" not in row.email_masked
diff --git a/packages/meshbay-hub/tests/test_site_basics.py b/packages/meshbay-hub/tests/test_site_basics.py
new file mode 100644
index 0000000..a3da39a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_site_basics.py
@@ -0,0 +1,81 @@
+"""
+The files every website is expected to have at the root of its origin.
+
+They live in the hub's static directory, which is mounted at "/", so every hub
+serves them — meshbay.org included, because its Caddyfile hands the hub every
+path the public site does not name.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+from meshbay_hub.app import create_app
+
+CADDYFILE = Path(__file__).resolve().parents[3] / "packaging" / "caddy" / "meshbay.org.Caddyfile"
+
+
+@pytest.fixture(scope="module")
+def client():
+ return TestClient(create_app())
+
+
+def test_robots_keeps_crawlers_out_of_the_signed_in_views(client):
+ r = client.get("/robots.txt")
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("text/plain")
+ rules = re.findall(r"^Disallow:\s*(\S+)", r.text, re.M)
+ assert "/app/" in rules and "/v1/" in rules
+ # A crawler rendering the sign-in page needs its scripts and stylesheet.
+ assert not any(rule in ("/", "/a/", "/app") for rule in rules), rules
+
+
+@pytest.mark.parametrize("path, magic", [
+ ("/favicon.ico", b"\x00\x00\x01\x00"),
+ ("/apple-touch-icon.png", b"\x89PNG"),
+])
+def test_the_icons_are_served_from_the_root(client, path, magic):
+ """Browsers ask for both at the root whether or not a page links them."""
+ r = client.get(path)
+ assert r.status_code == 200
+ assert r.content.startswith(magic), f"{path} is not the image it claims to be"
+
+
+@pytest.mark.skipif(not CADDYFILE.exists(), reason="no Caddyfile in this tree")
+@pytest.mark.parametrize("path", ["/robots.txt", "/favicon.ico", "/apple-touch-icon.png"])
+def test_meshbay_org_sends_them_to_the_hub(path):
+ """The public site owns only the paths its matcher names; a file claimed
+ there would be looked for in /srv/meshbay/site and 404."""
+ m = re.search(r"^\s*@site path (.+)$", CADDYFILE.read_text(), re.M)
+ assert m, "the @site matcher is gone"
+ assert path not in m.group(1).split()
+
+
+def _og(html: str, prop: str) -> str | None:
+ m = re.search(rf'<meta property="og:{prop}" content="([^"]*)">', html)
+ return m and m.group(1)
+
+
+def test_a_link_to_the_hub_previews_with_the_logo():
+ """Messengers draw a link from og:title and og:image, and resolve only an
+ absolute image URL — so it names the hub's public name, and the file is
+ one the hub serves."""
+ from meshbay_hub.config import load_config
+ cfg = load_config()
+ cfg.identity.id = "hub.example.org"
+ client = TestClient(create_app(cfg))
+ for path in ("/", "/app"):
+ html = client.get(path).text
+ assert _og(html, "title") == "MeshBay"
+ assert _og(html, "image") == "https://hub.example.org/og-image.jpg", path
+ image = client.get("/og-image.jpg")
+ assert image.status_code == 200 and image.content.startswith(b"\xff\xd8")
+ assert len(image.content) < 300_000, "too heavy for some messengers to fetch"
+
+
+def test_the_preview_description_fits_in_a_preview():
+ """A preview shows a line or two and cuts the rest; a cut sentence says
+ nothing."""
+ from meshbay_hub.api.webapp import PREVIEW_DESCRIPTION
+ assert len(PREVIEW_DESCRIPTION) <= 60
diff --git a/packages/meshbay-hub/tests/test_welcome_layout_measured.py b/packages/meshbay-hub/tests/test_welcome_layout_measured.py
index da72f34..5a77cb9 100644
--- a/packages/meshbay-hub/tests/test_welcome_layout_measured.py
+++ b/packages/meshbay-hub/tests/test_welcome_layout_measured.py
@@ -40,34 +40,46 @@ def _items(*keys: str) -> str:
def _page() -> str:
li = _items
+ # The source row carries the full URL rather than the host name it shows:
+ # an unbreakable string longer than the real one.
+ docs = "".join(
+ f'<li><span class="welcome-docs-label">{_en(label)}</span>'
+ f'<span class="welcome-docs-links">{links}</span></li>'
+ for label, links in [
+ ("welcome.docs_source", "https://git.meshbay.org/meshbay.git/about/"),
+ ("welcome.docs_user", f"{_en('welcome.docs_quickstart')} · {_en('welcome.docs_userguide')}"),
+ ("welcome.docs_devel", f"{_en('welcome.docs_design')} · {_en('welcome.docs_protocol')}"),
+ ])
return textwrap.dedent(f"""
<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div>
<div class="nav-right"><button class="nav-btn">Login</button></div></nav>
<div class="layout"><main class="main"><div class="page-center"><div class="welcome">
- <div class="card login-card"><h2>Login</h2>
+ <div class="welcome-side"><div class="card login-card"><h2>Login</h2>
<form><input type="text" placeholder="Username" />
<input type="password" placeholder="Password" /><button>Login</button></form>
<div class="login-footer">No account? <a href="#/register">Register</a></div>
<div class="login-footer"><a href="#/reset">Forgot your passphrase?</a></div>
</div>
+ <div class="welcome-links"><a class="welcome-cta" href="#">{_en('welcome.download')}</a>
+ <a class="welcome-legal" href="#">{_en('welcome.legal')}</a></div></div>
<section class="welcome-pitch">
<h1 class="welcome-title">{_en('welcome.title')}</h1>
<p class="welcome-lead">{_en('welcome.lead')}</p>
<ul class="welcome-apps">{li('welcome.app_chat', 'welcome.app_photos',
'welcome.app_media', 'welcome.app_video', 'welcome.app_music')}</ul>
- <p>{_en('welcome.groups')}</p>
- <p class="welcome-e2e"><span>{_en('welcome.e2e')}</span></p>
+ <h2 class="welcome-h">{_en('welcome.how_title')}</h2>
+ <ol class="welcome-steps">{li('welcome.step_home', 'welcome.step_anywhere',
+ 'welcome.step_share')}</ol>
<h2 class="welcome-h">{_en('welcome.uses_title')}</h2>
<ul class="welcome-uses">{li('welcome.use_chat', 'welcome.use_photos',
'welcome.use_media', 'welcome.use_apps')}</ul>
- <div class="welcome-hub"><h2 class="welcome-h">{_en('welcome.hub_title')}</h2>
- <p>{_en('welcome.hub_body')}</p>
- <p class="welcome-hub-never">{_en('welcome.hub_never')}</p>
- <ul class="welcome-never">{li('welcome.never_transit', 'welcome.never_stored',
- 'welcome.never_e2e')}</ul>
- <p class="welcome-hub-free">{_en('welcome.hub_free')}</p></div>
- <div class="welcome-links"><a class="welcome-cta" href="#">{_en('welcome.download')}</a>
- <a class="welcome-legal" href="#">{_en('welcome.legal')}</a></div>
+ <div class="welcome-private"><span class="welcome-private-icon"></span><div>
+ <h2 class="welcome-private-title">{_en('welcome.private_title')}</h2>
+ <p>{_en('welcome.private_body')}</p>
+ <ul class="welcome-badges">{li('welcome.badge_free', 'welcome.badge_open',
+ 'welcome.badge_no_ads', 'welcome.badge_no_tracking')}</ul></div></div>
+ <div class="welcome-docs"><h2 class="welcome-h">{_en('welcome.docs_title')}</h2>
+ <ul>{docs}</ul></div>
</section>
</div></div></main></div>
""")
@@ -76,7 +88,8 @@ def _page() -> str:
PHONES = [320, 360, 412]
DESKTOPS = [1100, 1440]
WIDTHS = PHONES + [768] + DESKTOPS
-SELECTORS = [".welcome", ".login-card", ".welcome-pitch"]
+SELECTORS = [".welcome", ".login-card", ".welcome-pitch", ".welcome-steps", ".welcome-docs",
+ ".welcome-links"]
@pytest.fixture(scope="module")
@@ -140,3 +153,14 @@ def test_the_pair_is_centred_in_the_window(measured):
middle = measured["1440"]["docScrollW"] / 2
centre = box["left"] + box["width"] / 2
assert abs(centre - middle) <= 2, f"the page is centred on x={centre}, not {middle}"
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_download_and_legal_links_sit_under_the_form(measured, width):
+ card, links = _box(measured, width, ".login-card"), _box(measured, width, ".welcome-links")
+ assert links["top"] >= card["top"] + card["height"], (
+ f"at {width} px the links are not below the sign-in form")
+ assert links["top"] - (card["top"] + card["height"]) <= 40, (
+ f"at {width} px the links are far below the form")
+ assert abs(links["left"] - card["left"]) <= 1 and abs(links["width"] - card["width"]) <= 1, (
+ f"at {width} px the links are not aligned with the form")