55a8be051a
Second §19.2 settlement after v1. New §6.7 alongside §6.6: optional `funder:` frontmatter field names a single gitea_login; a `funder_consents` app-db row records funder-side opt-in; both halves required for the binding to activate (two-key rule). Funder universe replaces — does not augment — the operator universe per-RFC for attribution-clean resolution. Funder role grants zero §6.1/§6.3 authority. Three revocation paths each restore the operator-credentials status quo. §19.2's credential-delegation entry is split: lighter half marked settled with a pointer to §6.7; operational-realities half (mid-call failure, rotation, billing, rate-limit attribution) lives on as its own entry. Test suite is 125/125 green (106 prior + 19 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
700 lines
30 KiB
Python
700 lines
30 KiB
Python
"""Integration coverage for the §19.2 "per-RFC credential delegation —
|
|
funder role + grant shape" candidate folded into §6.7.
|
|
|
|
The §6.7 settlement adds an optional `funder:` frontmatter field
|
|
(parallel to `owners:` / `arbiters:` / `models:`) plus a
|
|
`funder_consents` app-db record. Both halves are required for the
|
|
binding to be operationally active — the spec calls this the
|
|
"frontmatter + consent hybrid" two-key rule. When in effect, the
|
|
funder universe replaces (not augments) the operator universe for the
|
|
RFC's picker.
|
|
|
|
The tests below prove each branch through the API surface and the
|
|
resolver: the two-key rule under all four combinations, the
|
|
universe-replaces-not-augments rule, the three revocation paths, the
|
|
operator-enabled gate on credential registration, the consent-needs-
|
|
credentials gate, the §10.2 PR-draft and §8.12 chat routing through
|
|
funder credentials, and the entry parser round-trip for the new
|
|
frontmatter field.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
from test_propose_vertical import ( # noqa: F401
|
|
FakeGitea,
|
|
app_with_fake_gitea,
|
|
provision_user_row,
|
|
sign_in_as,
|
|
tmp_env,
|
|
)
|
|
from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401
|
|
|
|
|
|
SEED_BODY = (
|
|
"Open Human Model is a framework for representing humans.\n\n"
|
|
"It defines consent, trait, and agency in compatible terms."
|
|
)
|
|
|
|
|
|
def _set_funder_login(slug: str, login: str | None) -> None:
|
|
"""Write `cached_rfcs.funder_login` directly. Mirrors what
|
|
`_upsert_cached_rfc` would do on the next reconciler sweep, without
|
|
requiring a full meta-repo refresh in tests focused on the resolver."""
|
|
from app import db
|
|
db.conn().execute(
|
|
"UPDATE cached_rfcs SET funder_login = ? WHERE slug = ?",
|
|
(login, slug),
|
|
)
|
|
|
|
|
|
def _install_two_providers(app) -> None:
|
|
"""Operator universe of two picker keys — `claude` (Anthropic family)
|
|
and `gemini` (Google family). The funder tests exercise the
|
|
family-aware funder routing, so the operator must enable picker
|
|
keys from at least two distinct families."""
|
|
app.state.providers.clear()
|
|
app.state.providers["claude"] = FakeProvider("operator claude says hello")
|
|
app.state.providers["gemini"] = FakeProvider("operator gemini says hello")
|
|
|
|
|
|
def _patch_funder_construct(monkeypatch, marker: str = "from-funder-creds"):
|
|
"""Intercept `providers.construct_for_funder` so tests can verify
|
|
when funder credentials are actually used. Returns a FakeProvider
|
|
whose responses are tagged with the marker."""
|
|
from app import providers as providers_mod
|
|
funder_provider = FakeProvider(f"TITLE: {marker}\nDESCRIPTION: {marker} description")
|
|
|
|
def fake_construct(picker_key, api_key):
|
|
# Tag the returned provider with the key it was asked for so the
|
|
# test can assert routing went through the funder layer.
|
|
funder_provider._last_picker_key = picker_key
|
|
funder_provider._last_api_key = api_key
|
|
return funder_provider
|
|
|
|
monkeypatch.setattr(providers_mod, "construct_for_funder", fake_construct)
|
|
return funder_provider
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Two-key rule: frontmatter + consent both required
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_frontmatter_only_no_consent_falls_back_to_operator(app_with_fake_gitea):
|
|
"""`funder:` named in frontmatter without a matching `funder_consents`
|
|
row is operationally inert per §6.7. The resolver returns None for
|
|
the funder-universe lookup; the picker reads as the operator universe."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
# Frontmatter names alice as funder, but alice has not consented
|
|
# and has no registered credentials.
|
|
_set_funder_login("ohm", "alice")
|
|
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert r.status_code == 200
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
|
|
|
|
|
def test_consent_only_no_frontmatter_falls_back_to_operator(app_with_fake_gitea):
|
|
"""`funder_consents` row without a matching `funder:` frontmatter
|
|
field is operationally inert per §6.7's two-key rule. The picker
|
|
reads as the operator universe regardless of what the consenting
|
|
user has registered."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
# Alice has consented and registered, but frontmatter does not
|
|
# name her as funder.
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
|
|
|
|
|
def test_both_present_activates_funder_universe(app_with_fake_gitea):
|
|
"""Frontmatter + consent + credentials match → the picker resolves
|
|
to the intersection of the operator's enabled keys and the funder's
|
|
registered families. Alice registered Anthropic only; the picker
|
|
narrows to `claude`."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
_set_funder_login("ohm", "alice")
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
# Operator has {claude, gemini}; funder registered Anthropic only.
|
|
# The intersection is {claude}.
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
|
assert r.json()["default"] == "claude"
|
|
|
|
|
|
def test_neither_present_operator_universe(app_with_fake_gitea):
|
|
"""The v1 default and status quo: no `funder:` field and no consent
|
|
rows — the picker is the operator universe."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Funder universe REPLACES operator universe — not augments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_funder_universe_replaces_operator_universe(app_with_fake_gitea):
|
|
"""§6.7's attribution-clean rule: the funder universe replaces the
|
|
operator universe. A funder who registered Anthropic only —
|
|
irrespective of what the operator otherwise enables — sees the
|
|
picker narrowed to keys served by Anthropic. The funder cannot
|
|
expand beyond the operator's enabled set, and the operator's other
|
|
families are dropped (not added) when the funder is active."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app) # operator: claude + gemini
|
|
_set_funder_login("ohm", "alice")
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
# The funder is active. Anthropic family alone serves `claude`.
|
|
# `gemini` (a Google-family key the operator enabled) is dropped
|
|
# because the funder hasn't registered Google credentials.
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
|
|
|
|
|
def test_funder_with_no_matching_family_falls_into_opt_out_shape(app_with_fake_gitea):
|
|
"""When the consenting funder has no credentials whose family is
|
|
served by the operator's enabled set, the resolved universe is
|
|
empty — same shape as `models: []`. AI surfaces refuse honestly."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
# Operator universe: only Anthropic-family.
|
|
app.state.providers.clear()
|
|
app.state.providers["claude"] = FakeProvider("op-claude")
|
|
_set_funder_login("ohm", "alice")
|
|
# Funder has registered Google credentials only — no overlap.
|
|
funder.upsert_credential(2, "google", "alice-google-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert r.json()["models"] == []
|
|
|
|
|
|
def test_funder_intersects_with_models_frontmatter(app_with_fake_gitea):
|
|
"""§6.6 + §6.7 compose: the resolved list is the §6.6 `models:` list
|
|
intersected with the funder's registered universe (not the operator
|
|
universe). If `models: [gemini]` is set but the funder only
|
|
registered Anthropic, the intersection is empty and the RFC drops
|
|
into the opt-out shape."""
|
|
from fastapi.testclient import TestClient
|
|
from app import db, funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app) # operator: claude + gemini
|
|
_set_funder_login("ohm", "alice")
|
|
db.conn().execute(
|
|
"UPDATE cached_rfcs SET models_json = ? WHERE slug = 'ohm'",
|
|
(json.dumps(["gemini"]),),
|
|
)
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
# §6.6 names {gemini}; funder universe is {claude}; intersection
|
|
# is empty.
|
|
assert r.json()["models"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Three revocation paths — each restores operator-credentials status quo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_funder_withdraws_consent_flips_back_to_operator(app_with_fake_gitea):
|
|
"""§6.7's first revocation path: the funder withdraws consent. The
|
|
next AI-surface call resolves through the operator universe."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
_set_funder_login("ohm", "alice")
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
# Sanity: the binding is active and narrows the picker.
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
|
|
|
# Alice withdraws consent via the §17 endpoint.
|
|
r = client.delete("/api/rfcs/ohm/funder/consent")
|
|
assert r.status_code == 200
|
|
|
|
# The picker flips back to the operator universe immediately.
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
|
|
|
|
|
def test_frontmatter_removal_flips_back_to_operator(app_with_fake_gitea):
|
|
"""§6.7's second revocation path: an admin/owner/arbiter edits the
|
|
frontmatter to remove the field. The cache mirror picks up the
|
|
change and the resolution flips back without touching consent."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
_set_funder_login("ohm", "alice")
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude"]
|
|
|
|
# Frontmatter edit removes the field — the cache mirror reflects
|
|
# what `_upsert_cached_rfc` would write on the next sweep.
|
|
_set_funder_login("ohm", None)
|
|
|
|
r = client.get("/api/rfcs/ohm/models")
|
|
assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §17 /api/users/me/funder surface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_funder_self_surface_lists_credentials_without_api_keys(app_with_fake_gitea):
|
|
"""The read endpoint must never return the API key itself, only the
|
|
presence-of-registration metadata. The list is one row per
|
|
registered family."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
_install_two_providers(app)
|
|
funder.upsert_credential(2, "anthropic", "secret-anthropic-key")
|
|
funder.upsert_credential(2, "google", "secret-google-key")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.get("/api/users/me/funder")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
providers_listed = sorted(c["provider"] for c in body["credentials"])
|
|
assert providers_listed == ["anthropic", "google"]
|
|
# The API key itself must not appear anywhere in the response —
|
|
# the surface returns only registration metadata.
|
|
assert "secret-anthropic-key" not in r.text
|
|
assert "secret-google-key" not in r.text
|
|
assert "api_key" not in r.text
|
|
assert body["consents"] == []
|
|
|
|
|
|
def test_register_credential_refused_when_operator_has_not_enabled_family(app_with_fake_gitea):
|
|
"""§6.7: the funder cannot expand the operator universe. Registering
|
|
Google credentials when the operator has only enabled Anthropic
|
|
keys is refused."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
# Operator enables only Anthropic-family keys.
|
|
app.state.providers.clear()
|
|
app.state.providers["claude"] = FakeProvider("op-claude")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.post(
|
|
"/api/users/me/funder/credentials",
|
|
json={"provider": "google", "api_key": "alice-google-key"},
|
|
)
|
|
assert r.status_code == 409, r.text
|
|
# Anthropic should be accepted since the operator has enabled
|
|
# `claude` (an Anthropic-family picker key).
|
|
r = client.post(
|
|
"/api/users/me/funder/credentials",
|
|
json={"provider": "anthropic", "api_key": "alice-anthropic-key"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def test_consent_refused_without_any_registered_credentials(app_with_fake_gitea):
|
|
"""§6.7: a consent without any registered credentials would be
|
|
operationally inert (the funder universe would be empty). The
|
|
endpoint refuses rather than silently writing the row."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.post("/api/rfcs/ohm/funder/consent")
|
|
assert r.status_code == 409, r.text
|
|
|
|
|
|
def test_consent_endpoint_round_trip(app_with_fake_gitea):
|
|
"""The opt-in / opt-out cycle through the API surface: register a
|
|
credential, consent to fund a slug, see it on the self-read, and
|
|
withdraw."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
client.post(
|
|
"/api/users/me/funder/credentials",
|
|
json={"provider": "anthropic", "api_key": "alice-anthropic-key"},
|
|
)
|
|
r = client.post("/api/rfcs/ohm/funder/consent")
|
|
assert r.status_code == 200
|
|
|
|
r = client.get("/api/users/me/funder")
|
|
assert r.json()["consents"] == ["ohm"]
|
|
|
|
r = client.delete("/api/rfcs/ohm/funder/consent")
|
|
assert r.status_code == 200
|
|
|
|
r = client.get("/api/users/me/funder")
|
|
assert r.json()["consents"] == []
|
|
|
|
|
|
def test_delete_credential_leaves_consents_intact(app_with_fake_gitea):
|
|
"""Per §6.7: deleting a credential leaves `funder_consents` rows in
|
|
place; the resolved funder universe for affected RFCs simply
|
|
shrinks. If the funder later re-registers, those RFCs resume
|
|
drawing on the funder's universe without re-consenting."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
funder.upsert_credential(2, "anthropic", "key1")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.delete("/api/users/me/funder/credentials/anthropic")
|
|
assert r.status_code == 200
|
|
|
|
r = client.get("/api/users/me/funder")
|
|
body = r.json()
|
|
assert body["credentials"] == []
|
|
assert body["consents"] == ["ohm"] # consent survives
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §10.2 PR-draft and chat surfaces route through funder credentials
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pr_draft_routes_through_funder_credentials(app_with_fake_gitea, monkeypatch):
|
|
"""When a consenting funder is in effect on an RFC, the §10.2 draft
|
|
endpoint constructs a provider with the funder's API key rather
|
|
than reusing the operator's instance. The patched
|
|
`construct_for_funder` records the picker key and the API key it
|
|
was called with — both must come from the funder's registration."""
|
|
from fastapi.testclient import TestClient
|
|
from app import db, funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
_set_funder_login("ohm", "alice")
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
funder_provider = _patch_funder_construct(monkeypatch, marker="alice-funded")
|
|
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
|
branch = r.json()["branch_name"]
|
|
|
|
repo_full = "wiggleverse/rfc-0001-ohm"
|
|
owner, repo = repo_full.split("/", 1)
|
|
new_body = SEED_BODY + "\n\nA further sentence."
|
|
fake.files[(owner, repo, branch, "RFC.md")] = {
|
|
"content": new_body, "sha": fake._next_sha(),
|
|
}
|
|
fake.branches[(owner, repo)][branch]["sha"] = fake.files[
|
|
(owner, repo, branch, "RFC.md")
|
|
]["sha"]
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE cached_branches SET head_sha = ?
|
|
WHERE rfc_slug = 'ohm' AND branch_name = ?
|
|
""",
|
|
(fake.branches[(owner, repo)][branch]["sha"], branch),
|
|
)
|
|
|
|
r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft")
|
|
assert r.status_code == 200, r.text
|
|
# The funder-constructed provider was the one called — its
|
|
# marker comes back in the title.
|
|
assert r.json()["title"] == "alice-funded"
|
|
# And the funder's API key was the one used.
|
|
assert funder_provider._last_api_key == "alice-anthropic-key"
|
|
assert funder_provider._last_picker_key == "claude"
|
|
|
|
|
|
def test_pr_draft_falls_back_to_stub_when_funder_lacks_default_family(app_with_fake_gitea):
|
|
"""If the funder is consenting but has not registered credentials
|
|
for the family that serves the RFC's default model, the §10.2
|
|
surface falls back to the deterministic stub — same behavior as
|
|
operator-side opt-out. The lighter half deliberately does not
|
|
fall back to operator credentials per §6.7's attribution-clean
|
|
rule."""
|
|
from fastapi.testclient import TestClient
|
|
from app import db, funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
_set_funder_login("ohm", "alice")
|
|
# Funder consented but registered Google only. Operator's default
|
|
# would have been `claude` (Anthropic family); the resolved
|
|
# universe collapses to `gemini`, and the §10.2 draft proceeds
|
|
# against `gemini`. We test the *failure* path: register no
|
|
# credentials at all and the universe collapses to empty.
|
|
funder.add_consent(2, "ohm") # add consent without credentials
|
|
# But the add_consent helper bypasses the API gate; the resolver
|
|
# still treats this as "consenting funder with no registered
|
|
# families" — empty universe → opt-out shape.
|
|
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
|
branch = r.json()["branch_name"]
|
|
|
|
repo_full = "wiggleverse/rfc-0001-ohm"
|
|
owner, repo = repo_full.split("/", 1)
|
|
new_body = SEED_BODY + "\n\nA further sentence."
|
|
fake.files[(owner, repo, branch, "RFC.md")] = {
|
|
"content": new_body, "sha": fake._next_sha(),
|
|
}
|
|
fake.branches[(owner, repo)][branch]["sha"] = fake.files[
|
|
(owner, repo, branch, "RFC.md")
|
|
]["sha"]
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE cached_branches SET head_sha = ?
|
|
WHERE rfc_slug = 'ohm' AND branch_name = ?
|
|
""",
|
|
(fake.branches[(owner, repo)][branch]["sha"], branch),
|
|
)
|
|
|
|
r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft")
|
|
assert r.status_code == 200, r.text
|
|
# Empty resolved universe → deterministic stub per Slice 3.
|
|
assert r.json()["title"] == "Edits to OHM"
|
|
|
|
|
|
def test_chat_stream_routes_through_funder_credentials(app_with_fake_gitea, monkeypatch):
|
|
"""The §8.12 chat surface also picks up the funder routing. A chat
|
|
turn on a branch of an RFC with a consenting funder constructs the
|
|
provider with the funder's API key — same routing as §10.2."""
|
|
from fastapi.testclient import TestClient
|
|
from app import funder
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
|
_install_two_providers(app)
|
|
_set_funder_login("ohm", "alice")
|
|
funder.upsert_credential(2, "anthropic", "alice-anthropic-key")
|
|
funder.add_consent(2, "ohm")
|
|
|
|
funder_provider = _patch_funder_construct(monkeypatch, marker="from-funder")
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
|
display_name="Alice", role="contributor")
|
|
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
|
branch = r.json()["branch_name"]
|
|
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
|
thread_id = view["main_thread_id"]
|
|
|
|
r = client.post(
|
|
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat",
|
|
json={"text": "tighten this", "model": "claude"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
# The funder's API key was the one used by the constructed provider.
|
|
assert funder_provider._last_api_key == "alice-anthropic-key"
|
|
assert funder_provider._last_picker_key == "claude"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache round-trip and entry parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_meta_repo_frontmatter_funder_round_trips_through_cache(app_with_fake_gitea):
|
|
"""The production path: a meta-repo entry whose frontmatter carries
|
|
`funder: alice` lands in `cached_rfcs.funder_login` after the next
|
|
`refresh_meta_repo` sweep. Absent frontmatter lands NULL."""
|
|
from fastapi.testclient import TestClient
|
|
from app import cache as cache_mod, db
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
entry_text = (
|
|
"---\n"
|
|
"slug: ohm\n"
|
|
"title: OHM\n"
|
|
"state: super-draft\n"
|
|
"id: null\n"
|
|
"repo: null\n"
|
|
"proposed_by: ben\n"
|
|
"proposed_at: 2026-05-23\n"
|
|
"graduated_at: null\n"
|
|
"graduated_by: null\n"
|
|
"owners: []\n"
|
|
"arbiters: []\n"
|
|
"tags: []\n"
|
|
"funder: alice\n"
|
|
"---\n\n"
|
|
"The pitch goes here.\n"
|
|
)
|
|
sha = fake._next_sha()
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")] = {
|
|
"content": entry_text, "sha": sha,
|
|
}
|
|
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
|
|
|
|
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
|
|
|
|
row = db.conn().execute(
|
|
"SELECT funder_login FROM cached_rfcs WHERE slug = 'ohm'"
|
|
).fetchone()
|
|
assert row is not None
|
|
assert row["funder_login"] == "alice"
|
|
|
|
# An entry without `funder:` lands NULL.
|
|
no_funder_text = entry_text.replace("funder: alice\n", "")
|
|
sha2 = fake._next_sha()
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
|
|
"content": no_funder_text.replace("slug: ohm", "slug: beta")
|
|
.replace("title: OHM", "title: Beta"),
|
|
"sha": sha2,
|
|
}
|
|
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha2
|
|
|
|
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
|
|
|
|
row = db.conn().execute(
|
|
"SELECT funder_login FROM cached_rfcs WHERE slug = 'beta'"
|
|
).fetchone()
|
|
assert row is not None
|
|
assert row["funder_login"] is None
|
|
|
|
|
|
def test_entry_funder_field_round_trip():
|
|
"""parse(serialize(x)) preserves the funder field — None stays None
|
|
(no key emitted), populated stays populated. Mirrors the §6.6
|
|
absent/empty/populated test for `models:`."""
|
|
from app import entry as entry_mod
|
|
|
|
absent = entry_mod.Entry(slug="x", title="X")
|
|
assert absent.funder is None
|
|
text_absent = entry_mod.serialize(absent)
|
|
assert "funder:" not in text_absent
|
|
round_absent = entry_mod.parse(text_absent)
|
|
assert round_absent.funder is None
|
|
|
|
populated = entry_mod.Entry(slug="y", title="Y", funder="alice")
|
|
text_populated = entry_mod.serialize(populated)
|
|
assert "funder: alice" in text_populated
|
|
round_populated = entry_mod.parse(text_populated)
|
|
assert round_populated.funder == "alice"
|