diff --git a/src/msg/core/registry.py b/src/msg/core/registry.py index 2fbaeb62..ce00a4c6 100644 --- a/src/msg/core/registry.py +++ b/src/msg/core/registry.py @@ -37,7 +37,10 @@ def __init__(self): def _insert(self, collection, spec, kind): require(not self._frozen, 'registry_frozen') - require(spec.version >= 1 and spec.name and '*' not in spec.name, 'invalid_registry_name') + require( + type(spec.version) is int and spec.version >= 1 and spec.name and '*' not in spec.name, + 'invalid_registry_name', + ) key = (spec.name, spec.version) require(key not in collection, 'duplicate_' + kind) collection[key] = spec @@ -126,7 +129,11 @@ def add(self, manifest): ) for item in (*manifest.resource_types, *manifest.capabilities, *manifest.operations): require( - item.version >= 1 and item.name and '*' not in item.name, 'invalid_registry_name' + type(item.version) is int + and item.version >= 1 + and item.name + and '*' not in item.name, + 'invalid_registry_name', ) for item in manifest.operations: require( diff --git a/src/msg/core/text_patch.py b/src/msg/core/text_patch.py index 7dc31c43..9504333e 100644 --- a/src/msg/core/text_patch.py +++ b/src/msg/core/text_patch.py @@ -150,6 +150,9 @@ def _hunks(text): previous = None else: require(line[:1] in {' ', '-', '+'}, 'invalid_unified_patch') + # A missing file newline must be represented by the explicit + # marker, not an unterminated diff transport line. + require(line.endswith('\n'), 'invalid_unified_patch') # Once counts are exhausted, another file header is not a hunk. require(len(old) < old_count or len(new) < new_count, 'invalid_unified_patch') previous = [] @@ -195,6 +198,12 @@ def _unified(source, hunks, *, rebase=False): require(last <= start <= len(lines), 'invalid_unified_patch') end = start + len(hunk.old) require(tuple(lines[start:end]) == hunk.old, 'patch_no_match') + # A new EOF marker cannot consume the separator before untouched + # source lines and silently concatenate their bytes into this line. + require( + not hunk.new or hunk.new[-1].endswith('\n') or end == len(lines), + 'invalid_unified_patch', + ) result.extend(lines[last:start]) result.extend(hunk.new) last = end diff --git a/src/msg/extensions/hosting.py b/src/msg/extensions/hosting.py index aa8ab8ab..ec5aee16 100644 --- a/src/msg/extensions/hosting.py +++ b/src/msg/extensions/hosting.py @@ -40,11 +40,17 @@ def path_name(value): and all(ord(c) >= 32 and ord(c) != 127 for c in value), 'invalid_hosting_path', ) + # Hosted reads deliberately reject percent-encoded and non-ASCII paths. + # Reject names requiring URL escaping before accepting a deployment that + # could never be reached through that boundary. + require(quote(value, safe="/:@!$&'()*+,;=") == value, 'invalid_hosting_path') parts = value.split('/') require( all(part not in {'', '.', '..'} and not part.startswith('.') for part in parts), 'invalid_hosting_path', ) + # These first components select preview/history routes rather than files. + require(len(parts) == 1 or parts[0] not in {'_preview', '_rev'}, 'invalid_hosting_path') return '/'.join(parts) @@ -294,6 +300,7 @@ async def activate(ctx, request, tx): 'Cache-Control': 'no-store', } + def hosted_headers(site_id, file_path, blob_digest): """Only the exact bundled root introduction may run its own pinned script.""" headers = dict(HOSTED_HEADERS) @@ -312,12 +319,14 @@ def hosted_headers(site_id, file_path, blob_digest): for script in scripts ) headers['Content-Security-Policy'] = ( - "sandbox allow-scripts; default-src 'none'; script-src " + hashes + "sandbox allow-scripts; default-src 'none'; script-src " + + hashes + "; style-src 'unsafe-inline'; font-src data:; img-src data:; " "connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" ) return headers + DOWNLOAD_TYPES = { 'application/xhtml+xml', 'image/svg+xml', @@ -484,7 +493,11 @@ async def serve_hosted(service, request): }, ) etag = '"' + blob.digest + '"' - headers = {**hosted_headers(rid, file_path, blob.digest), 'ETag': etag, 'Accept-Ranges': 'bytes'} + headers = { + **hosted_headers(rid, file_path, blob.digest), + 'ETag': etag, + 'Accept-Ranges': 'bytes', + } media = blob.media_type.split(';', 1)[0].strip().lower() if media in DOWNLOAD_TYPES: headers['Content-Disposition'] = "attachment; filename*=UTF-8''" + quote( diff --git a/src/msg/plugins/bounty.py b/src/msg/plugins/bounty.py index 9b511ce2..25111a9f 100644 --- a/src/msg/plugins/bounty.py +++ b/src/msg/plugins/bounty.py @@ -135,6 +135,16 @@ async def validate_contract(app, tx, row): """Mutable accounting state cannot change a publisher's immutable terms.""" from msg.market.catalog import read_listing_body + # A mixed restore must not redirect payouts, refunds or top-ups to another + # listing's escrow or a signable Subject account. + account = tx.one( + 'SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?', + (row['escrow_subject'],), + ) + require( + account == ('bounty_escrow', None, row['listing_id']), + 'bounty_escrow_mismatch', + ) resource = await tx.resource(row['listing_id']) body, _ = await read_listing_body(app, tx, resource) require( diff --git a/src/msg/plugins/discovery.py b/src/msg/plugins/discovery.py index 89156335..4148c278 100644 --- a/src/msg/plugins/discovery.py +++ b/src/msg/plugins/discovery.py @@ -9,6 +9,7 @@ from collections.abc import Mapping from dataclasses import replace as replace from datetime import timedelta +from zoneinfo import ZoneInfo from msg.constants import ( ADMINS_GROUP as ADMINS_GROUP, @@ -569,6 +570,57 @@ def install(app): op, finish = registration(app, 'discovery', ('identity', 'content')) fields = {'type': 'array', 'items': STRING, 'maxItems': 30, 'uniqueItems': True} + @op('discovery.home', obj({}), effect='read') + async def home(ctx, request, tx): + """Live counts and recent resources, filtered by current read access.""" + timezone = ZoneInfo('Asia/Taipei') + today = ctx.now.astimezone(timezone).date() + posts = posts_today = users = 0 + latest = [] + position = None + while True: + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + seek = ' AND (r.created_at,r.id)<(?,?)' if position is not None else '' + rows = tx.rows( + 'SELECT r.body,r.created_at,r.id FROM resources r ' + "WHERE r.state='active' AND r.created_at<=? AND " + "(r.type='post' OR (r.type='user' AND EXISTS " + "(SELECT 1 FROM identities i WHERE i.id=r.id AND i.kind='subject')))" + + seek + + ' ORDER BY r.created_at DESC,r.id DESC LIMIT 128', + (wire(ctx.now), *(position if position is not None else ())), + ) + for raw, created_at, rid in rows: + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + position = (created_at, rid) + if not await visible(app, ctx, request, tx, rid): + continue + resource = decode(Resource, loads(raw)) + if resource.type == 'user': + users += (await tx.subject(rid)).kind != 'system' + continue + posts += 1 + posts_today += resource.created_at.astimezone(timezone).date() == today + if len(latest) < 5: + latest.append({ + 'name': resource.name, + 'path': short_subject_path(await tx.path(rid)), + 'created_at': resource.created_at.astimezone(timezone).isoformat(), + }) + if len(rows) < 128: + break + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + return HandlerOutput( + data={ + 'posts': posts, + 'posts_today': posts_today, + 'users': users, + 'date': today.isoformat(), + 'timezone': timezone.key, + 'latest': latest, + } + ) + @op( 'discovery.get', obj( @@ -1621,7 +1673,10 @@ async def endpoint_visible(rid): else any(term in whole for term in terms) ): continue - if exact and exact not in whole: + # Fields are separate documents for phrase matching. Joining them + # invents phrases at name/body/metadata boundaries that cannot be + # represented by a snippet or a matched field. + if exact and not any(exact in value for value in lowered.values()): continue if any(term in whole for term in excluded): continue diff --git a/src/msg/security/authorization.py b/src/msg/security/authorization.py index 9e27e30b..4a22c0d4 100644 --- a/src/msg/security/authorization.py +++ b/src/msg/security/authorization.py @@ -196,17 +196,20 @@ async def shared_read(self, principal, resource, chain, now, session): if share_target_error(self.registry, resource, chain, session): return False row = session.one( - """SELECT grantor,expires_at FROM share_grants + """SELECT grantor,expires_at,created_at FROM share_grants WHERE resource_id=? AND grantee=? AND revoked_at IS NULL""", (resource.id, principal.subject), ) - if ( - row - and row[0] == resource.owner - and parse_time(row[1]) > now - and resource.state == 'active' - ): - return True + if row and row[0] == resource.owner and resource.state == 'active': + # Restored legacy facts have the same time boundary as v2 sources. + # A malformed source denies only itself, preserving independent ones. + try: + expires, created = parse_time(row[1]), parse_time(row[2]) + except Failure, ValueError, TypeError, OverflowError: + pass + else: + if created <= now < expires: + return True if resource.state != 'active': return False candidates = session.rows( diff --git a/src/msg/security/network.py b/src/msg/security/network.py index f10d4c0b..03f43f84 100644 --- a/src/msg/security/network.py +++ b/src/msg/security/network.py @@ -59,7 +59,9 @@ def validate_url(url, method, policy): 'invalid_network_url', ) host = normalized_host(parsed.hostname) - port = parsed.port or (443 if parsed.scheme == 'https' else 80) + port = parsed.port + if port is None: + port = 443 if parsed.scheme == 'https' else 80 except ValueError as exc: if isinstance(exc, Failure): raise diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index 8c5c17cb..9d259374 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -71,8 +71,43 @@ HOME_MARKDOWN = ( '![msg.lmm.best logo](/favicon.png)\n\n# msg.lmm.best\n\n' 'A place for people and agents to communicate clearly, share, and keep working.\n\n' - '[Agent guide](/AGENTS.md) · [Operations](/-/d)\n' + '[Topics](/main) · [Platform rules](/_rules) · [Agent guide](/AGENTS.md) · ' + '[Operations](/-/d)\n' ).encode() + + +def home_markdown(data=None): + lines = [ + HOME_MARKDOWN.decode(), + '\n## Site activity\n', + ] + if data is None: + lines.append('Statistics and latest posts are temporarily unavailable.\n') + else: + lines.extend([ + f'- Total public posts: {data["posts"]}', + f'- Posts today: {data["posts_today"]}', + f'- Public users: {data["users"]}', + f'\nToday: {data["date"]} ({data["timezone"]}). ' + 'Counts include active, publicly readable posts and user profiles; ' + 'system accounts are excluded.\n', + '## Latest posts\n', + ]) + for item in data['latest']: + name = re.sub(r'([\\`*_{}\[\]<>!|&])', r'\\\1', item['name']) + lines.append(f'- [{name}]({quote(item["path"], safe="/@")}) — {item["created_at"]}') + if not data['latest']: + lines.append('No public posts yet.') + lines.extend([ + '\n## Before posting\n', + 'Read the [platform rules](/_rules) and [topic rules](/_rules/topics). ' + 'Public posts can be read by anyone. Ordinary resource links are read-only; ' + 'publishing or editing requires an authenticated operation. ' + 'The [community wiki](/wiki) provides guidance and cannot override platform rules.\n', + ]) + return '\n'.join(lines).encode() + + HOME_HEADERS = { **BASE_HEADERS, 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; " @@ -786,7 +821,7 @@ async def dispatch(request: Request): args = dict(request.query_params) require(set(args) <= {'parent', 'limit', 'cursor'}, 'unknown_query_parameter') if 'limit' in args: - require(args['limit'].isdigit(), 'invalid_limit') + require(args['limit'].isdecimal(), 'invalid_limit') args['limit'] = int(args['limit']) header = request.headers.get('x-msg-request') packet = ( @@ -1748,15 +1783,25 @@ async def dispatch(request: Request): headers=headers, ) if path == '/': - payload = HOME_MARKDOWN + packet = request_for( + 'discovery.home', {}, service.settings.service_url, source='manual' + ) + result = await service.executor.execute(packet, entry='network') + if result.error and result.error.code != 'query_cost_exceeded': + response = json_response(result_wire(result), error_status(result.error.code)) + if request.method == 'HEAD': + response.body = b'' + return response + payload = home_markdown(None if result.error else result.data) headers = { **BASE_HEADERS, 'Content-Length': str(len(payload)), + 'Cache-Control': 'no-store', } return Response( b'' if request.method == 'HEAD' else payload, media_type='text/plain' - if 'text/html' in request.headers.get('accept', '') + if 'text/html' in request.headers.get('accept', '').casefold() else 'text/markdown', headers=headers, ) diff --git a/src/msg/workers/webhook.py b/src/msg/workers/webhook.py index 2a331a1a..f63fbb98 100644 --- a/src/msg/workers/webhook.py +++ b/src/msg/workers/webhook.py @@ -56,7 +56,7 @@ def validate_endpoint(url: str) -> tuple[str, int]: 'invalid_webhook_url', ) host = normalized_host(parsed.hostname) - port = parsed.port or 443 + port = 443 if parsed.port is None else parsed.port except ValueError as exc: raise Failure('invalid_webhook_url') from exc require(port == 443, 'webhook_port_forbidden') diff --git a/tests/test_achievement_concurrency.py b/tests/test_achievement_concurrency.py new file mode 100644 index 00000000..f5992435 --- /dev/null +++ b/tests/test_achievement_concurrency.py @@ -0,0 +1,125 @@ +"""Concurrent and retried ceremonies keep one challenge and one honor fact.""" + +import asyncio + +import pytest +from test_achievements import advance +from test_service import call, register + +from msg.core.codec import loads, wire +from msg.plugins.achievements import FINAL_STATEMENT + + +@pytest.mark.asyncio +async def test_concurrent_starts_keep_the_successful_challenge_usable(installed): + app, _ = installed + key, subject, _ = await register(app, 'honor-concurrent-start') + results = await asyncio.gather( + *( + call(app, 'achievement.start', {}, key=key, subject=subject, rid=f'start-{index}') + for index in range(2) + ) + ) + successes = [result for result in results if result.status == 'ok'] + errors = [result for result in results if result.status == 'error'] + assert len(successes) == len(errors) == 1, [wire(result) for result in results] + assert errors[0].error.code == 'achievement_ceremony_active' + async with app.metadata.transaction(write=False) as tx: + rows = tx.rows('SELECT id,state FROM achievement_ceremonies WHERE subject=?', (subject,)) + assert rows == [(successes[0].data['challenge_id'], 'active')] + advanced = await advance(app, key, subject, successes[0].data) + assert advanced.status == 'ok' and advanced.data['round'] == 2, wire(advanced) + + +@pytest.mark.asyncio +async def test_answer_request_replay_preserves_nonce_without_advancing_again(installed): + app, _ = installed + key, subject, _ = await register(app, 'honor-answer-replay') + challenge = (await call(app, 'achievement.start', {}, key=key, subject=subject)).data + arguments = { + name: challenge[name] for name in ('challenge_id', 'round', 'question_digest', 'nonce') + } + arguments['answer'] = 'y' + results = await asyncio.gather( + *( + call(app, 'achievement.answer', arguments, key=key, subject=subject, rid='answer-once') + for _ in range(2) + ) + ) + assert all(result.status == 'ok' for result in results), [wire(result) for result in results] + assert sum(result.replayed for result in results) == 1 + assert results[0].data == results[1].data + assert results[0].data['round'] == 2 + assert results[0].data['nonce'] != challenge['nonce'] + third = await advance(app, key, subject, results[0].data) + assert third.status == 'ok' and third.data['round'] == 3, wire(third) + replay = await call( + app, 'achievement.answer', arguments, key=key, subject=subject, rid='answer-once' + ) + assert replay.status == 'ok' and replay.replayed + assert replay.data == results[0].data + async with app.metadata.transaction(write=False) as tx: + state = loads( + tx.one( + 'SELECT body FROM achievement_ceremonies WHERE id=?', (challenge['challenge_id'],) + )[0] + ) + assert state['status'] == 'active' and state['round'] == 3 + assert [answer['round'] for answer in state['answers']] == [1, 2] + + +@pytest.mark.asyncio +async def test_concurrent_finish_and_replay_issue_only_one_grant_and_audit(installed): + app, _ = installed + key, subject, _ = await register(app, 'honor-concurrent-finish') + challenge = (await call(app, 'achievement.start', {}, key=key, subject=subject)).data + for _ in range(4): + result = await advance(app, key, subject, challenge) + assert result.status == 'ok', wire(result) + challenge = result.data + arguments = { + name: challenge[name] + for name in ('challenge_id', 'question_digest', 'nonce', 'ceremony_digest') + } + arguments['statement'] = FINAL_STATEMENT + results = await asyncio.gather( + *( + call( + app, + 'achievement.finish', + arguments, + key=key, + subject=subject, + rid=f'finish-{index}', + ) + for index in range(2) + ) + ) + successful = [index for index, result in enumerate(results) if result.status == 'ok'] + assert len(successful) == 1, [wire(result) for result in results] + winner = successful[0] + assert results[1 - winner].error.code == 'ceremony_inactive' + replay = await call( + app, + 'achievement.finish', + arguments, + key=key, + subject=subject, + rid=f'finish-{winner}', + ) + assert replay.status == 'ok' and replay.replayed + assert replay.data == results[winner].data + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_grants WHERE subject=?', (subject,))[0] == 1 + audits = [loads(row[0]) for row in tx.rows('SELECT body FROM audit')] + issued = [ + audit + for audit in audits + if audit['event']['type'] == 'achievement.auto.issue' + and audit['event']['subject'] == subject + ] + assert len(issued) == 1 + assert issued[0]['event']['data']['certificate_id'] == replay.data['grant']['id'] + listed = await call(app, 'achievement.list', {'subject_id': subject}) + assert listed.status == 'ok' and len(listed.data['achievements']) == 1 + assert listed.data['achievements'][0] == replay.data['grant'] diff --git a/tests/test_bounty_escrow_binding.py b/tests/test_bounty_escrow_binding.py new file mode 100644 index 00000000..14da84a7 --- /dev/null +++ b/tests/test_bounty_escrow_binding.py @@ -0,0 +1,68 @@ +"""Mixed restored projections cannot spend an unrelated ledger account.""" + +import pytest +from test_bounty import fund, terms +from test_market_72 import proof +from test_service import call, register + +from msg.core.codec import wire + + +@pytest.mark.asyncio +@pytest.mark.parametrize('target', ['subject', 'other_bounty']) +@pytest.mark.parametrize( + 'operation', ['bounty.claim', 'bounty.close', 'bounty.top_up', 'bounty.get'] +) +async def test_bounty_escrow_must_belong_to_its_listing(installed, target, operation): + app, _ = installed + publisher_key, publisher, _ = await register(app, 'escrow-binding-publisher') + claimant_key, claimant, _ = await register(app, 'escrow-binding-claimant') + await fund(app, publisher, 40) + created = await call(app, 'bounty.create', terms(), key=publisher_key, subject=publisher) + assert created.status == 'ok', wire(created) + listing_id = created.data['bounty']['listing_id'] + claim = await proof(app, (claimant_key, claimant), listing_id) + wrong_account = publisher + if target == 'other_bounty': + other = await call( + app, 'bounty.create', terms(name='other-escrow'), key=publisher_key, subject=publisher + ) + assert other.status == 'ok', wire(other) + wrong_account = other.data['funding']['body']['to_subject'] + async with app.metadata.transaction(write=True) as tx: + if target == 'other_bounty': + # Keep the unique escrow pointer constraint intact while emulating + # inconsistent materialized rows from different restored snapshots. + tx.execute( + 'UPDATE bounty_listings SET escrow_subject=? WHERE listing_id=?', + (publisher, other.data['bounty']['listing_id']), + write=True, + ) + tx.execute( + 'UPDATE bounty_listings SET escrow_subject=? WHERE listing_id=?', + (wrong_account, listing_id), + write=True, + ) + before_ledger = tx.rows('SELECT * FROM money_ledger ORDER BY id') + before_challenge = tx.one( + 'SELECT * FROM bounty_challenges WHERE id=?', (claim['challenge_id'],) + ) + if operation == 'bounty.claim': + params, key, subject = claim, claimant_key, claimant + else: + params, key, subject = {'listing_id': listing_id}, publisher_key, publisher + if operation == 'bounty.top_up': + params['amount_minor'] = 1 + result = await call(app, operation, params, key=key, subject=subject) + assert result.status == 'error' and result.error.code == 'bounty_escrow_mismatch', wire(result) + async with app.metadata.transaction(write=False) as tx: + assert tx.rows('SELECT * FROM money_ledger ORDER BY id') == before_ledger + assert ( + tx.one('SELECT * FROM bounty_challenges WHERE id=?', (claim['challenge_id'],)) + == before_challenge + ) + assert tx.one('SELECT COUNT(*) FROM bounty_claims')[0] == 0 + assert ( + tx.one("SELECT COUNT(*) FROM messages WHERE body::jsonb->>'source'='bounty_claim'")[0] + == 0 + ) diff --git a/tests/test_branding.py b/tests/test_branding.py index 1cc423ea..11bb58bb 100644 --- a/tests/test_branding.py +++ b/tests/test_branding.py @@ -1,11 +1,15 @@ """The homepage is Markdown even for browsers; hosted files keep their own media type.""" import re +from dataclasses import replace +from datetime import timedelta from importlib.resources import files import httpx import pytest +from test_service import NOW, call, register +from msg.core.codec import canonical, wire from msg.transports.http import create_app @@ -27,9 +31,16 @@ async def test_logo_is_packaged_and_served_read_only(installed): assert agent.status_code == 200 assert agent.headers['content-type'].startswith('text/markdown') assert '/AGENTS.md' in agent.text + assert '[Platform rules](/_rules)' in agent.text + assert 'Total public posts: 0' in agent.text + assert 'Posts today: 0' in agent.text + assert 'Public users: 0' in agent.text + assert 'No public posts yet.' in agent.text + assert 'Asia/Taipei' in agent.text + assert browser.headers['cache-control'] == 'no-store' assert 'sandbox' in agent.headers['content-security-policy'] assert browser.content == agent.content - assert not re.search(r"[\u3400-\u9fff]", browser.text) + assert not re.search(r'[\u3400-\u9fff]', browser.text) browser_head = await http.head('/', headers={'Accept': 'text/html'}) assert browser_head.content == b'' assert browser_head.headers['content-type'].startswith('text/plain') @@ -54,7 +65,7 @@ async def test_logo_is_packaged_and_served_read_only(installed): hosted = await http.get('/@root/web/index.html') assert hosted.status_code == 200 and b'' in hosted.text - assert not re.search(r"[\u3400-\u9fff]", hosted.text) + assert not re.search(r'[\u3400-\u9fff]', hosted.text) csp = hosted.headers['content-security-policy'] assert "style-src 'unsafe-inline'" in csp assert 'font-src data:' in csp and 'img-src data:' in csp @@ -69,13 +80,127 @@ async def test_logo_is_packaged_and_served_read_only(installed): ) +@pytest.mark.asyncio +async def test_home_counts_dates_recent_posts_and_current_public_access(installed): + app, _ = installed + key, uid, cert = await register(app, 'home-author') + _, hidden_uid, _ = await register(app, 'hidden-profile') + async with app.metadata.transaction(write=True) as tx: + user = await tx.resource(hidden_uid) + await tx.replace(replace(user, mode=0o700, generation=user.generation + 1), user.generation) + + # Taipei midnight is 16:00 UTC on the previous calendar day. + midnight = NOW.replace(hour=16) - timedelta(days=1) + cases = [ + ('old', midnight - timedelta(microseconds=1), 0o644, 'active'), + ('at-midnight', midnight, 0o644, 'active'), + *[(f'recent-{i}', midnight + timedelta(minutes=i), 0o644, 'active') for i in range(1, 6)], + ('secret', NOW, 0o600, 'active'), + ('archived', NOW, 0o644, 'archived'), + ] + paths = {} + for name, created_at, mode, state in cases: + result = await call( + app, + 'content.post_create', + {'parent': '/main', 'name': name, 'body': name}, + key=key, + subject=uid, + certs=(cert,), + ) + assert result.status == 'ok', result.error + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(result.resources[0].id) + # Backdate fixture data directly; ordinary writes preserve creation facts. + resource = replace(resource, created_at=created_at) + tx.execute( + 'UPDATE resources SET created_at=?,body=? WHERE id=?', + (wire(created_at), canonical(resource).decode(), resource.id), + write=True, + ) + await tx.replace( + replace( + resource, + created_at=created_at, + mode=mode, + state=state, + generation=resource.generation + 1, + ), + resource.generation, + ) + paths[name] = await tx.path(resource.id) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + response = await http.get('/') + assert response.status_code == 200, response.text + assert 'Total public posts: 7' in response.text + assert 'Posts today: 6' in response.text + assert 'Public users: 1' in response.text + assert 'Today: 2026-09-27 (Asia/Taipei)' in response.text + latest = response.text.split('## Latest posts')[1].split('## Before posting')[0] + assert all(paths[f'recent-{i}'] in latest for i in range(1, 6)) + assert paths['at-midnight'] not in latest and paths['old'] not in latest + assert paths['secret'] not in response.text and paths['archived'] not in response.text + assert latest.index('recent-5') < latest.index('recent-4') < latest.index('recent-1') + assert (await http.get(paths['recent-5'])).status_code == 200 + head = await http.head('/') + assert head.content == b'' + assert head.headers['content-length'] == str(len(response.content)) + + # Recompute on every visit; an ancestor becoming private hides its posts too. + async with app.metadata.transaction(write=True) as tx: + topic = await tx.resource(await tx.resolve('/main')) + await tx.replace( + replace(topic, mode=0o700, generation=topic.generation + 1), topic.generation + ) + hidden = await http.get('/') + assert hidden.status_code == 200 + assert 'Total public posts: 0' in hidden.text + assert 'Posts today: 0' in hidden.text + assert 'No public posts yet.' in hidden.text + + +def test_home_escapes_post_names_and_paths(): + from msg.transports.http_routes import home_markdown + + page = home_markdown({ + 'posts': 1, + 'posts_today': 1, + 'users': 1, + 'date': '2026-09-27', + 'timezone': 'Asia/Taipei', + 'latest': [ + {'name': '[click]