Post-v1: per-RFC credential delegation (funder role) folded into §6.7
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>
This commit is contained in:
@@ -0,0 +1,699 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user