Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21743a08b1 | |||
| c92730a737 | |||
| 0f8b318afa |
+144
@@ -23,6 +23,105 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
|
## 0.6.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — no operator action required.** A sweep-the-edges hardening
|
||||||
|
release (roadmap item #4, "anon discuss + contribute off-limits") that
|
||||||
|
audits every write-shaped backend endpoint and asserts each one
|
||||||
|
enforces an explicit `auth.require_contributor` (or stricter) gate
|
||||||
|
before doing any state-changing work. v0.3.0 hid write affordances
|
||||||
|
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
|
||||||
|
discussion surface with its own write gate; v0.6.0 sweeps the rest
|
||||||
|
and adds a regression test net so future endpoints can't quietly
|
||||||
|
ship without a gate. No schema migration, no env-var changes, no
|
||||||
|
new dependencies. The only behavioural change is one tightening: the
|
||||||
|
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
|
||||||
|
`auth.require_user` since it surfaces operator-visible step detail
|
||||||
|
(repo name, PR number, rollback steps) not part of the v0.3.0
|
||||||
|
anonymous-read contract for catalog/RFC bodies.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
|
||||||
|
now calls `auth.require_user(request)` as its first line.
|
||||||
|
Anonymous callers receive 401 instead of being able to subscribe
|
||||||
|
to a graduation's SSE. The floor is `require_user` (not
|
||||||
|
`require_contributor`) so a write-muted operator can still observe
|
||||||
|
the progress of a graduation they kicked off before being muted.
|
||||||
|
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
|
||||||
|
documents the v0.6.0 audit: every write-shaped endpoint in §17
|
||||||
|
enforces an explicit gate; anonymous writes refuse 401. The list
|
||||||
|
of audited write families is recorded in-line.
|
||||||
|
- **SPEC `§10.10`** — the discussion-vs-contribution section now
|
||||||
|
records that the v0.5.0 write gates have a regression test net
|
||||||
|
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
|
||||||
|
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
|
||||||
|
bullet now documents the `require_user` gate added in v0.6.0,
|
||||||
|
with the rationale.
|
||||||
|
|
||||||
|
### Added (tests)
|
||||||
|
|
||||||
|
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
|
||||||
|
tests asserting that every write-shaped endpoint surveyed in the
|
||||||
|
v0.6.0 audit refuses anonymous callers with 401, and that the five
|
||||||
|
anonymous-read surfaces (health, philosophy, auth/me, catalog,
|
||||||
|
RFC view, discussion threads, proposals) stay reachable. Sixty-six
|
||||||
|
assertions in total, covering: propose; proposal merge / decline /
|
||||||
|
withdraw; branch promote-to-branch / start-edit-branch / metadata /
|
||||||
|
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
|
||||||
|
/ messages (POST) / resolve / chat-seen / changes (accept / decline
|
||||||
|
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
|
||||||
|
PR pr-draft / open / seen / review / merge / withdraw /
|
||||||
|
description / resolution-branch; discussion thread create + message
|
||||||
|
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
|
||||||
|
the admin reads; notification preferences / quiet-hours / watch /
|
||||||
|
mark-read / user-mute (POST + DELETE); funder credentials (POST +
|
||||||
|
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
|
||||||
|
progress SSE; PR review page anonymous-readable.
|
||||||
|
|
||||||
|
### Anonymous-writeable allowlist
|
||||||
|
|
||||||
|
Two endpoints are intentionally anonymous-by-design. They are not
|
||||||
|
audit findings; they are documented here so the contract is
|
||||||
|
explicit:
|
||||||
|
|
||||||
|
- `GET /auth/login` and `GET /auth/callback` — the OAuth
|
||||||
|
round-trip. Anonymous-by-design because they ARE the sign-in
|
||||||
|
entrypoint.
|
||||||
|
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
|
||||||
|
— anonymous in the session sense but authenticated by HMAC shared
|
||||||
|
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
|
||||||
|
respectively). The webhook receiver is the wrong place for an
|
||||||
|
authenticated session; the shared-secret shape is correct.
|
||||||
|
|
||||||
|
### §19.2 candidates surfaced
|
||||||
|
|
||||||
|
- None unique to this release. The two pre-existing candidates the
|
||||||
|
audit touched — anonymous-read polish for the discussion surface
|
||||||
|
(carried from v0.5.0) and the operator-visibility floor on
|
||||||
|
graduation progress — were settled here as `require_user` on
|
||||||
|
`/graduate/progress` rather than deferred.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.5.0)
|
||||||
|
|
||||||
|
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||||||
|
npm run build`) so the frontend bundle's reported version matches
|
||||||
|
the backend's. No new env vars; existing `frontend/.env` is
|
||||||
|
sufficient.
|
||||||
|
2. The deployment **MUST** restart the backend so the new
|
||||||
|
`require_user` gate on `/graduate/progress` is enforced. No
|
||||||
|
schema migration runs.
|
||||||
|
3. The deployment **MAY** announce the audit completion to
|
||||||
|
operators: every write-shaped backend endpoint now enforces an
|
||||||
|
explicit gate, and the `test_anon_offlimits_vertical.py` test
|
||||||
|
net asserts the contract on every CI run. The audited surfaces
|
||||||
|
are listed in the `Added (tests)` section above and in `SPEC.md`
|
||||||
|
§6.1.
|
||||||
|
4. The deployment **MUST NOT** assume any new envelope behaviour:
|
||||||
|
v0.6.0 is purely a hardening release. No schema, no env, no
|
||||||
|
dependency changes; the operator's role is reduced to rebuild +
|
||||||
|
restart.
|
||||||
|
|
||||||
## 0.5.0 — 2026-05-27
|
## 0.5.0 — 2026-05-27
|
||||||
|
|
||||||
**Minor — no operator action required.** This release wires the
|
**Minor — no operator action required.** This release wires the
|
||||||
@@ -120,6 +219,51 @@ No schema migration is required.
|
|||||||
v0.5.0's write paths already refuse anonymous posts, so no
|
v0.5.0's write paths already refuse anonymous posts, so no
|
||||||
pre-emptive operator action is needed.
|
pre-emptive operator action is needed.
|
||||||
|
|
||||||
|
## 0.4.0 — 2026-05-27
|
||||||
|
|
||||||
|
**Minor — no operator action required beyond rebuild + restart.** The
|
||||||
|
proposer of a new RFC is now the implicit first owner of its super-
|
||||||
|
draft entry, set automatically at propose time from the session user.
|
||||||
|
The §13.1 claim flow remains available for *additional* owners. No
|
||||||
|
schema changes, no env-var changes, no API-shape changes; only newly
|
||||||
|
proposed RFCs receive the auto-owner — existing super-drafts whose
|
||||||
|
`owners:` is empty are unaffected and can still be claimed via §13.1
|
||||||
|
as before. This is a §19.3 rule-2 spec correction: `SPEC.md` §9.1,
|
||||||
|
§9.2, and §13.1 are updated to reflect the new shape.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`POST /api/rfcs/propose`** (`backend/app/api.py`) now sets
|
||||||
|
`Entry.owners = [user.gitea_login]` when constructing the new
|
||||||
|
super-draft entry, instead of `owners=[]`. The session's
|
||||||
|
`gitea_login` is the canonical source; the endpoint never accepted
|
||||||
|
an owner field from the request payload and still doesn't.
|
||||||
|
- **`SPEC.md` §9.1** narrowed: the "no proposed-owner or working-
|
||||||
|
group fields" sentence becomes a proposer-owner-auto / working-
|
||||||
|
group-deferred split, with a §19.3 rule-2 note.
|
||||||
|
- **`SPEC.md` §9.2** frontmatter shape: `owners: []` → `owners:
|
||||||
|
[<proposer.gitea_login>]`, with a §19.3 rule-2 note.
|
||||||
|
- **`SPEC.md` §13.1** reframed: claim flow is now a graduation-time
|
||||||
|
broadening for additional owners, not a precondition for the
|
||||||
|
proposer's own RFC. The §13.1 / §13.2 / §13.3 graduation pipeline
|
||||||
|
itself is unchanged — the "at least one owner" precondition for
|
||||||
|
graduation still holds and is now satisfied by default.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.3.0)
|
||||||
|
|
||||||
|
1. The deployment **MUST** rebuild and restart per the routine
|
||||||
|
deploy steps. No `.env` changes, no schema/migration changes, no
|
||||||
|
API-shape changes.
|
||||||
|
2. Operators **SHOULD** note that newly proposed RFCs after the
|
||||||
|
upgrade carry the proposer in `owners:` automatically. Existing
|
||||||
|
super-drafts with empty `owners:` are not migrated; they remain
|
||||||
|
claimable via the §13.1 flow exactly as before. No deployment-
|
||||||
|
side data action is required.
|
||||||
|
3. Deployments **MAY** communicate the UX shift to active proposers
|
||||||
|
(the "Claim ownership" affordance no longer applies to your own
|
||||||
|
newly proposed RFC), but the affordance simply hides on RFCs the
|
||||||
|
viewer already owns, so no operator-side action is required.
|
||||||
|
|
||||||
## 0.3.0 — 2026-05-27
|
## 0.3.0 — 2026-05-27
|
||||||
|
|
||||||
**Minor — operator action required if a deployment wants to enable the
|
**Minor — operator action required if a deployment wants to enable the
|
||||||
|
|||||||
@@ -354,7 +354,15 @@ Authorization is owned by the app. Gitea sees only the bot account.
|
|||||||
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
||||||
every RFC repo's main branch), read any branch whose `read_public`
|
every RFC repo's main branch), read any branch whose `read_public`
|
||||||
is true, read any PR. Cannot chat, propose, create branches, or
|
is true, read any PR. Cannot chat, propose, create branches, or
|
||||||
open PRs.
|
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
|
||||||
|
write-shaped endpoint surveyed in §17 enforces an explicit
|
||||||
|
`auth.require_contributor` (or stricter) gate before doing any
|
||||||
|
state-changing work; anonymous writes refuse 401. The explicit
|
||||||
|
audit covers propose, branch create, branch threads, PR-less
|
||||||
|
discussion threads + messages, PR open / merge / withdraw,
|
||||||
|
funder credentials + consent, admin allowlist add, graduation
|
||||||
|
kickoff + claim. Anonymous reads on every catalog and RFC-body
|
||||||
|
surface remain open per the v0.3.0 contract.
|
||||||
2. **Contributor.** Default role for any authenticated account.
|
2. **Contributor.** Default role for any authenticated account.
|
||||||
Everything anonymous can do, plus: propose new RFCs (open a PR
|
Everything anonymous can do, plus: propose new RFCs (open a PR
|
||||||
against the meta repo), create branches on any RFC repo, open PRs
|
against the meta repo), create branches on any RFC repo, open PRs
|
||||||
@@ -1125,12 +1133,20 @@ The modal collects four fields:
|
|||||||
type their own.
|
type their own.
|
||||||
|
|
||||||
No proposer name or email — the logged-in identity is canonical and
|
No proposer name or email — the logged-in identity is canonical and
|
||||||
need not be retyped. No proposed-owner or working-group fields —
|
need not be retyped. No proposer-owner field either: the proposer is
|
||||||
ownership flows through the post-merge claim flow (§13.1), and
|
implicitly the first owner of their own RFC, set automatically at
|
||||||
|
submit-time from the session user (see §9.2); the post-merge claim
|
||||||
|
flow (§13.1) remains for *other* contributors to add themselves as
|
||||||
|
owners on an RFC they didn't propose. No working-group field —
|
||||||
arbiters are admin work, not the proposer's call. AI's drafting role
|
arbiters are admin work, not the proposer's call. AI's drafting role
|
||||||
is intentionally narrow: tag suggestions only. The proposer is
|
is intentionally narrow: tag suggestions only. The proposer is
|
||||||
making a specific claim about a specific word, and having AI propose
|
making a specific claim about a specific word, and having AI propose
|
||||||
the claim for them would undercut the gesture.
|
the claim for them would undercut the gesture. (§19.3 rule-2
|
||||||
|
correction: 0.4.0 narrowed this paragraph; the prior version asserted
|
||||||
|
"no proposed-owner field" without qualification, but running code
|
||||||
|
revealed that forcing a separate claim-flow gesture for the
|
||||||
|
proposer's own RFC was UX friction with no benefit — the proposer
|
||||||
|
already self-identified by proposing.)
|
||||||
|
|
||||||
Primary action: **"Open proposal PR"** — naming the actual Git
|
Primary action: **"Open proposal PR"** — naming the actual Git
|
||||||
artifact produced, consistent with §10.1's *Open PR* and §13's
|
artifact produced, consistent with §10.1's *Open PR* and §13's
|
||||||
@@ -1146,7 +1162,13 @@ is populated from the modal and session:
|
|||||||
- `state: super-draft`, `id: null`, `repo: null`,
|
- `state: super-draft`, `id: null`, `repo: null`,
|
||||||
`graduated_at: null`, `graduated_by: null` — fixed at creation.
|
`graduated_at: null`, `graduated_by: null` — fixed at creation.
|
||||||
- `proposed_by: <session email>`, `proposed_at: <today>` — auto.
|
- `proposed_by: <session email>`, `proposed_at: <today>` — auto.
|
||||||
- `owners: []` — empty; the claim flow (§13.1) fills this.
|
- `owners: [<proposer.gitea_login>]` — the proposer is the first
|
||||||
|
owner at propose time, set automatically from the session user.
|
||||||
|
The §13.1 claim flow remains available for *additional* owners on
|
||||||
|
the same entry. (§19.3 rule-2 correction: 0.4.0 changed this from
|
||||||
|
`owners: []`; the prior shape required a separate claim-flow
|
||||||
|
gesture for the proposer's own RFC, which running code surfaced
|
||||||
|
as needless UX friction.)
|
||||||
- `arbiters: []` — empty; arbiters are admin work, not the
|
- `arbiters: []` — empty; arbiters are admin work, not the
|
||||||
proposer's call.
|
proposer's call.
|
||||||
|
|
||||||
@@ -1655,8 +1677,12 @@ them was the failure mode of generic-PR-comments-as-only-conversation.
|
|||||||
|
|
||||||
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
|
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
|
||||||
contract: anyone can see the conversation. Writes require contributor
|
contract: anyone can see the conversation. Writes require contributor
|
||||||
role per §6.1 (v0.5.0 implements the gate; v0.6.0 — item #4 — hardens
|
role per §6.1: v0.5.0 implemented the gate on the three discussion
|
||||||
adjacent surfaces to match). The notification routing reuses the
|
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
|
||||||
|
#4) audited the adjacent surfaces and added the matching test net
|
||||||
|
(`test_anon_offlimits_vertical.py`) so a regression on any write
|
||||||
|
endpoint is caught immediately. The gates use `auth.require_contributor`
|
||||||
|
as the canonical helper. The notification routing reuses the
|
||||||
existing `chat_message_in_participated_thread` /
|
existing `chat_message_in_participated_thread` /
|
||||||
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
|
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
|
||||||
fan-out row; the §15.7 reconciler and §15 inbox prose render
|
fan-out row; the §15.7 reconciler and §15 inbox prose render
|
||||||
@@ -1757,11 +1783,20 @@ PR do not block graduation; they remain on the meta repo subject to
|
|||||||
|
|
||||||
### 13.1 Claim ownership (prerequisite)
|
### 13.1 Claim ownership (prerequisite)
|
||||||
|
|
||||||
If a super-draft has no owner, any signed-in contributor can click
|
The proposer is already the first owner of any super-draft they
|
||||||
"Claim ownership," which opens a PR against the meta repo adding their
|
proposed (per §9.2; auto-set at propose time). The claim flow exists
|
||||||
username to the `owners:` field of the entry. Owners and admins can
|
for *additional* contributors to become owners on an existing super-
|
||||||
merge. (A self-merge window for un-acted claims is not enabled in v1;
|
draft — typically a draft whose proposer has stepped away, or a draft
|
||||||
configurable later if needed.) Multiple claims simply append.
|
graduating with a working group. Any signed-in contributor can click
|
||||||
|
"Claim ownership," which opens a PR against the meta repo adding
|
||||||
|
their username to the `owners:` field of the entry. Owners and admins
|
||||||
|
can merge. (A self-merge window for un-acted claims is not enabled in
|
||||||
|
v1; configurable later if needed.) Multiple claims simply append.
|
||||||
|
(§19.3 rule-2 correction: 0.4.0 narrowed this section's framing; the
|
||||||
|
prior version implied owners always started empty and the claim flow
|
||||||
|
was a hard prerequisite to graduation, but with the proposer now
|
||||||
|
auto-set as first owner, the claim flow is a graduation-time
|
||||||
|
broadening rather than a precondition for the proposer's own RFC.)
|
||||||
|
|
||||||
### 13.2 The Graduate dialog
|
### 13.2 The Graduate dialog
|
||||||
|
|
||||||
@@ -2521,7 +2556,13 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
trailing `rollback` step's events if any earlier step fails. The
|
trailing `rollback` step's events if any earlier step fails. The
|
||||||
Graduate dialog opens this stream on confirm and renders the step
|
Graduate dialog opens this stream on confirm and renders the step
|
||||||
stack from the events. The stream closes on success or on
|
stack from the events. The stream closes on success or on
|
||||||
rollback completion.
|
rollback completion. Requires `auth.require_user` per v0.6.0
|
||||||
|
(item #4): the step detail (repo name, PR number, rollback steps)
|
||||||
|
is operator-visible state and isn't part of the v0.3.0
|
||||||
|
anonymous-read contract for catalog and RFC bodies. The floor is
|
||||||
|
`require_user` (not `require_contributor`) so a write-muted
|
||||||
|
operator can still observe a graduation they kicked off before
|
||||||
|
being muted.
|
||||||
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
||||||
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
||||||
PR number, title, author, last-activity timestamp, and the
|
PR number, title, author, last-activity timestamp, and the
|
||||||
|
|||||||
+5
-1
@@ -305,7 +305,11 @@ def make_router(
|
|||||||
proposed_at=entry_mod.today(),
|
proposed_at=entry_mod.today(),
|
||||||
graduated_at=None,
|
graduated_at=None,
|
||||||
graduated_by=None,
|
graduated_by=None,
|
||||||
owners=[],
|
# §9.2: the proposer is the implicit first owner at propose time.
|
||||||
|
# The §13.1 claim flow exists for *other* contributors to become
|
||||||
|
# owners on an RFC they didn't propose; the proposer never needs
|
||||||
|
# to claim their own RFC.
|
||||||
|
owners=[user.gitea_login],
|
||||||
arbiters=[],
|
arbiters=[],
|
||||||
tags=[t.strip() for t in payload.tags if t.strip()],
|
tags=[t.strip() for t in payload.tags if t.strip()],
|
||||||
body=payload.pitch.strip() + "\n",
|
body=payload.pitch.strip() + "\n",
|
||||||
|
|||||||
@@ -520,7 +520,16 @@ def make_router(
|
|||||||
|
|
||||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||||
async def graduate_progress(slug: str, request: Request):
|
async def graduate_progress(slug: str, request: Request):
|
||||||
del request
|
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
|
||||||
|
# detail (repo name, PR number, rollback steps) that isn't part of
|
||||||
|
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
|
||||||
|
# corresponding POST /graduate is gated to RFC owners/arbiters and
|
||||||
|
# app admins/owners via `_can_graduate`; the read SSE shares that
|
||||||
|
# operator-visible surface, so it requires at least an
|
||||||
|
# authenticated viewer. We keep the floor at require_user (not
|
||||||
|
# require_contributor) so a write-muted operator can still observe
|
||||||
|
# the progress of a graduation they kicked off before being muted.
|
||||||
|
auth.require_user(request)
|
||||||
state = _get_active(slug)
|
state = _get_active(slug)
|
||||||
if state is None:
|
if state is None:
|
||||||
raise HTTPException(404, "No graduation in flight for this slug")
|
raise HTTPException(404, "No graduation in flight for this slug")
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
|
||||||
|
vertical.
|
||||||
|
|
||||||
|
A sweep-the-edges hardening release. The v0.3.0 release hid the write
|
||||||
|
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
|
||||||
|
surface with its own write gate. v0.6.0 audits both: every write-shaped
|
||||||
|
endpoint refuses anonymous callers with 401 (or 403 when the role check
|
||||||
|
runs after the auth check), and every anonymous-read surface stays
|
||||||
|
reachable.
|
||||||
|
|
||||||
|
This test is the regression net for the audit. It walks each module's
|
||||||
|
representative write endpoint as an anonymous client and asserts the
|
||||||
|
401/403, then walks the same surfaces' representative read endpoints
|
||||||
|
as anonymous and asserts the 200. The intent is breadth over depth:
|
||||||
|
one assertion per write endpoint family is enough to catch a
|
||||||
|
regression where someone strips the `auth.require_contributor` line.
|
||||||
|
|
||||||
|
Endpoints covered (one or two from each module):
|
||||||
|
|
||||||
|
- api.py: propose, decline (admin), withdraw,
|
||||||
|
funder credentials POST/DELETE, funder consent
|
||||||
|
POST/DELETE
|
||||||
|
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
|
||||||
|
manual-flush, visibility, grants POST/DELETE,
|
||||||
|
threads POST, thread messages POST, resolve,
|
||||||
|
chat-seen, change accept/decline/reask
|
||||||
|
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
|
||||||
|
description, resolution-branch
|
||||||
|
- api_discussion.py: thread create, message post, resolve
|
||||||
|
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
|
||||||
|
- api_notifications.py: prefs POST, watch POST, mark-read POST,
|
||||||
|
quiet-hours POST, user-mute POST/DELETE
|
||||||
|
- api_graduation.py: graduate POST, claim POST, progress GET
|
||||||
|
|
||||||
|
The §15.7 reads (`/api/notifications`, `/api/watches`,
|
||||||
|
`/api/users/me/*`) are per-user surfaces — they require an
|
||||||
|
authenticated viewer by definition; an anonymous 401 on those reads is
|
||||||
|
shape-correct, not a regression. The test does not assert reads on
|
||||||
|
those.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
|
||||||
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
|
||||||
|
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
|
||||||
|
RFC view, the PR-less discussion surface, the philosophy page, and
|
||||||
|
the health probe must remain reachable for unauthenticated viewers.
|
||||||
|
This is the read side of the item #4 contract — the read surfaces
|
||||||
|
must NOT regress to require auth as the write gates tighten.
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
# No session cookie — viewer is anonymous.
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# The five read surfaces an anonymous viewer must reach.
|
||||||
|
assert client.get("/api/health").status_code == 200
|
||||||
|
assert client.get("/api/philosophy").status_code == 200
|
||||||
|
assert client.get("/api/auth/me").status_code == 200
|
||||||
|
assert client.get("/api/rfcs").status_code == 200
|
||||||
|
assert client.get("/api/rfcs/ohm").status_code == 200
|
||||||
|
assert client.get("/api/rfcs/ohm/main").status_code == 200
|
||||||
|
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
|
||||||
|
assert client.get("/api/proposals").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_propose_refused(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/propose",
|
||||||
|
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
|
||||||
|
"""The admin-gated proposal actions — merge, decline — must refuse
|
||||||
|
anonymous callers with 401 (the auth check runs before the role
|
||||||
|
check; both refusals are correct, but 401 is the structural signal
|
||||||
|
"no session at all")."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
# PR number doesn't need to exist — the gate runs first.
|
||||||
|
assert client.post("/api/proposals/1/merge").status_code == 401
|
||||||
|
assert (
|
||||||
|
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/proposals/1/withdraw").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
|
||||||
|
"""Branch-scoped writes on an active RFC: promote-to-branch,
|
||||||
|
manual-flush, visibility, grants, threads create, message post,
|
||||||
|
resolve, chat-seen, change accept/decline/reask. All must 401 for
|
||||||
|
anonymous callers."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Branch-scoped writes — slug + branch values are placeholders;
|
||||||
|
# the auth gate runs before any state lookup.
|
||||||
|
slug = "ohm"
|
||||||
|
branch = "feature-x"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
|
||||||
|
json={},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
|
||||||
|
json={"new_content": "hi", "paragraph_count": 1},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/visibility",
|
||||||
|
json={"read_public": False},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/grants",
|
||||||
|
json={"grantee_gitea_login": "alice"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads",
|
||||||
|
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
|
||||||
|
json={"text": "hi"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
|
||||||
|
json={"last_seen_message_id": 1},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
|
||||||
|
json={"proposed": "x"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
# Chat stream — POST shaped, same auth gate.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
|
||||||
|
json={"text": "hi"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
|
||||||
|
"""Super-draft-scoped writes: start-edit-branch and metadata. The
|
||||||
|
PR open / merge paths share the gate via api_prs.py — see the
|
||||||
|
PR-flow test below for those."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/anything/start-edit-branch", json={}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/anything/metadata", json={"title": "x"}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
|
||||||
|
"""All §10 PR-flow writes — open, merge, withdraw, description,
|
||||||
|
review, seen, pr-draft, resolution-branch — must 401 for anonymous."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
slug, branch, pr = "ohm", "feature-x", 1
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
|
||||||
|
json={"title": "t", "description": "d"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/seen",
|
||||||
|
json={"last_seen_message_id": 1},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/review",
|
||||||
|
json={"text": "x", "anchor_payload": {}},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/merge"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/description",
|
||||||
|
json={"title": "t", "description": "d"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
|
||||||
|
"""The v0.5.0 PR-less discussion surface — write gates must hold.
|
||||||
|
This duplicates the assertion in `test_discussion_vertical.py` and
|
||||||
|
keeps it here too as the canonical home for the item #4 audit."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"message": "drive-by"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads/1/messages",
|
||||||
|
json={"text": "drive-by"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads/1/resolve"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
|
||||||
|
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
|
||||||
|
The auth check runs before the require_admin role check, so the
|
||||||
|
response is 401."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/admin/users/1/role", json={"role": "admin"}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/admin/users/1/mute", json={"muted": True}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/admin/allowlist", json={"email": "x@y.z"}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
|
||||||
|
)
|
||||||
|
# Admin reads also gated.
|
||||||
|
assert client.get("/api/admin/users").status_code == 401
|
||||||
|
assert client.get("/api/admin/audit").status_code == 401
|
||||||
|
assert client.get("/api/admin/permission-events").status_code == 401
|
||||||
|
assert client.get("/api/admin/graduation-queue").status_code == 401
|
||||||
|
assert client.get("/api/admin/allowlist").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
|
||||||
|
"""Notification preference / watch / mark-read / user-mute writes —
|
||||||
|
all per-user surfaces, all require an authenticated viewer."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/users/me/notification-preferences",
|
||||||
|
json={"email_personal_direct": False},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/users/me/quiet-hours",
|
||||||
|
json={"start": None, "end": None, "timezone": None},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/notifications/1/read").status_code == 401
|
||||||
|
assert (
|
||||||
|
client.post("/api/notifications/read", json={}).status_code == 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/users/1/notification-mute").status_code == 401
|
||||||
|
assert client.delete("/api/users/1/notification-mute").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
|
||||||
|
"""§6.7 funder credential + consent writes — registering a key,
|
||||||
|
consenting to fund — all refuse anonymous callers."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/users/me/funder/credentials",
|
||||||
|
json={"provider": "anthropic", "api_key": "sk-test"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete(
|
||||||
|
"/api/users/me/funder/credentials/anthropic"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
|
||||||
|
"""§13 graduation: the POST kickoff and POST claim both refuse
|
||||||
|
anonymous. The progress SSE was gated to require_user in v0.6.0
|
||||||
|
(item #4) since it surfaces admin-internal step detail."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/anything/graduate",
|
||||||
|
json={
|
||||||
|
"rfc_id": "RFC-0001",
|
||||||
|
"repo_name": "rfc-0001-x",
|
||||||
|
"owners": ["alice"],
|
||||||
|
},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/rfcs/anything/claim").status_code == 401
|
||||||
|
# v0.6.0 tightening: progress SSE now requires require_user.
|
||||||
|
# No graduation is in flight, but the auth check runs first.
|
||||||
|
assert (
|
||||||
|
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
|
||||||
|
"""The PR review page is §11.3 universal-public — once a PR is
|
||||||
|
open, anonymous viewers can read it. This guards against a
|
||||||
|
regression where the read endpoint accidentally grows an auth
|
||||||
|
gate."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
# Seed an open PR row directly — the cache shape is enough for
|
||||||
|
# the read endpoint; the live Gitea fetch falls back gracefully.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cached_prs
|
||||||
|
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||||
|
opened_by, opened_at, head_branch, base_branch, head_sha)
|
||||||
|
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
|
||||||
|
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
client.cookies.clear()
|
||||||
|
# Anonymous read on an open PR: should be 200. The endpoint may
|
||||||
|
# surface a partial response (the FakeGitea won't have the head
|
||||||
|
# branch's RFC.md, so branch_body falls back to empty) but the
|
||||||
|
# auth gate must let the read through.
|
||||||
|
r = client.get("/api/rfcs/ohm/prs/7")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["capabilities"]["is_anonymous"] is True
|
||||||
|
assert body["capabilities"]["can_merge"] is False
|
||||||
|
assert body["capabilities"]["can_post_review"] is False
|
||||||
@@ -496,6 +496,11 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
|||||||
proposal = r.json()
|
proposal = r.json()
|
||||||
assert proposal["entry"]["title"] == "Open Human Model"
|
assert proposal["entry"]["title"] == "Open Human Model"
|
||||||
assert proposal["entry"]["state"] == "super-draft"
|
assert proposal["entry"]["state"] == "super-draft"
|
||||||
|
# §9.2: the proposer is the implicit first owner at propose time.
|
||||||
|
# The owners field is a single-element list containing exactly the
|
||||||
|
# session user's gitea_login — no request-supplied owner field
|
||||||
|
# exists or is honored.
|
||||||
|
assert proposal["entry"]["owners"] == ["alice"]
|
||||||
assert proposal["affordances"]["merge"] is True
|
assert proposal["affordances"]["merge"] is True
|
||||||
|
|
||||||
# Owner merges. The catalog picks up the new super-draft.
|
# Owner merges. The catalog picks up the new super-draft.
|
||||||
@@ -516,6 +521,10 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
|||||||
view = r.json()
|
view = r.json()
|
||||||
assert view["state"] == "super-draft"
|
assert view["state"] == "super-draft"
|
||||||
assert "shared definition" in view["body"]
|
assert "shared definition" in view["body"]
|
||||||
|
# §9.2: the auto-set proposer-owner survives the meta-repo round-trip
|
||||||
|
# — it's in the file's frontmatter on main after merge, not just
|
||||||
|
# in the pending-PR view above.
|
||||||
|
assert view["owners"] == ["alice"]
|
||||||
|
|
||||||
# The pending-ideas list no longer carries the merged proposal.
|
# The pending-ideas list no longer carries the merged proposal.
|
||||||
r = client.get("/api/proposals")
|
r = client.get("/api/proposals")
|
||||||
@@ -568,6 +577,37 @@ def test_anonymous_cannot_propose(app_with_fake_gitea):
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_proposer_is_auto_owner_request_payload_ignored(app_with_fake_gitea):
|
||||||
|
"""§9.2: the owners field on the new entry is always exactly
|
||||||
|
`[session.gitea_login]`. The propose endpoint never accepts an owner
|
||||||
|
from the client; a request payload that smuggles one in is ignored
|
||||||
|
by the Pydantic body model and the auto-set value lands instead.
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=11, login="alice", role="contributor")
|
||||||
|
sign_in_as(client, user_id=11, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
# Extra unknown fields like `owners` are dropped by the
|
||||||
|
# ProposeBody model; the session user is the only source of truth.
|
||||||
|
r = client.post("/api/rfcs/propose", json={
|
||||||
|
"title": "Spoof attempt",
|
||||||
|
"slug": "spoof-attempt",
|
||||||
|
"pitch": "p",
|
||||||
|
"tags": [],
|
||||||
|
"owners": ["mallory", "eve"],
|
||||||
|
"proposed_by": "mallory@test",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
pr_number = r.json()["pr_number"]
|
||||||
|
r = client.get(f"/api/proposals/{pr_number}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
entry = r.json()["entry"]
|
||||||
|
assert entry["owners"] == ["alice"]
|
||||||
|
# proposed_by also comes from the session, never the body.
|
||||||
|
assert entry["proposed_by"] in ("alice@test", "alice")
|
||||||
|
|
||||||
|
|
||||||
def test_withdraw_by_proposer_works(app_with_fake_gitea):
|
def test_withdraw_by_proposer_works(app_with_fake_gitea):
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
app, _fake = app_with_fake_gitea
|
app, _fake = app_with_fake_gitea
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
{proposeOpen && viewer && (
|
{proposeOpen && viewer && (
|
||||||
<ProposeModal
|
<ProposeModal
|
||||||
|
viewer={viewer}
|
||||||
onClose={() => setProposeOpen(false)}
|
onClose={() => setProposeOpen(false)}
|
||||||
onSubmitted={({ pr_number }) => {
|
onSubmitted={({ pr_number }) => {
|
||||||
setProposeOpen(false)
|
setProposeOpen(false)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function slugify(title) {
|
|||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProposeModal({ onClose, onSubmitted }) {
|
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [slugEdited, setSlugEdited] = useState(false)
|
const [slugEdited, setSlugEdited] = useState(false)
|
||||||
@@ -131,6 +131,12 @@ export default function ProposeModal({ onClose, onSubmitted }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{viewer && (
|
||||||
|
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
|
||||||
|
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> — you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="field-error">{error}</p>}
|
{error && <p className="field-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user