~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/web/grant_test.go -rw-r--r-- 4.5 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
package web

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-ecore/grants"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
)

// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "parse grants %q", s)
	return g
}

// grantRouter mounts the read plane with a fixed principal injected into every
// request, bypassing token resolution: what put the principal there is the
// resolver's business, and this file is about what the handlers do with it.
func grantRouter(t *testing.T, p authn.Principal) http.Handler {
	t.Helper()
	resolver, err := authn.NewResolver("bigbes", stubTokenStore{})
	require.NoError(t, err)

	srv, err := New(Options{
		Conf: ini.File{
			"sr.ht": ini.Section{
				"network-key": testConf.Section("sr.ht")["network-key"],
				"site-name":   "sourcehut",
				"environment": "development",
				"owner-name":  "bigbes",
			},
			"webhooks":   ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
			"spec.sr.ht": ini.Section{"origin": "https://spec.example"},
			"meta.sr.ht": ini.Section{"origin": "https://meta.example"},
		},
		Reader:   newFakeReader(),
		Searcher: &fakeSearcher{},
		Resolver: resolver,
	})
	require.NoError(t, err)

	r := chi.NewRouter()
	r.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			next.ServeHTTP(w, req.WithContext(authn.WithPrincipal(req.Context(), p)))
		})
	})
	srv.Register(r)
	return r
}

func instancePrincipal(t *testing.T, grantString string) authn.Principal {
	t.Helper()
	return authn.Principal{
		Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code", Session: "s-1",
		Plane: authn.PlaneInstance, Grants: mustGrants(t, grantString),
	}
}

// Every read route asks for spec:read from a tokens.sr.ht working token, and
// asks nothing extra of the credentials that carry no grants. The refusal is a
// 403 and pointedly not a login redirect: the caller is already authenticated,
// so sending it to meta would loop it back with the same token.
func TestReadRoutesRequireTheReadGrant(t *testing.T) {
	targets := []string{
		"/~bigbes/rfcs",
		"/~bigbes/rfcs/specs/0007-storage",
		"/~bigbes/rfcs/specs/0007-storage.md",
		"/~bigbes/rfcs/specs/0007-storage.json",
		"/search?q=storage",
		"/inbox",
	}

	t.Run("refused without it", func(t *testing.T) {
		h := grantRouter(t, instancePrincipal(t, "spec:propose"))
		for _, target := range targets {
			t.Run(target, func(t *testing.T) {
				rec := httptest.NewRecorder()
				h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
				assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body)
				assert.Contains(t, rec.Body.String(), authn.ActionRead)
				assert.Empty(t, rec.Header().Get("Location"),
					"an authenticated caller must not be redirected to a login")
			})
		}
	})

	// The credentials that work today, and the instance token that was minted
	// for reading: all served, no 403 anywhere.
	for name, p := range map[string]authn.Principal{
		"owner cookie":              {Kind: authn.KindOwner, Owner: "bigbes", CookieUser: "bigbes"},
		"local agent token":         {Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneLocal, TokenName: "laptop"},
		"instance token, spec:read": instancePrincipal(t, "spec:read"),
		"instance token, universal": instancePrincipal(t, "*"),
	} {
		t.Run("served for "+name, func(t *testing.T) {
			h := grantRouter(t, p)
			for _, target := range targets {
				rec := httptest.NewRecorder()
				h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
				assert.Equal(t, http.StatusOK, rec.Code, "%s: body %s", target, rec.Body)
			}
		})
	}
}

// An anonymous browser is still sent to meta's login and an anonymous bot still
// gets a 401: the grant check sits behind the identity one and does not change
// what happens when there is no identity at all.
func TestAnonymousDenialIsUnchanged(t *testing.T) {
	h := grantRouter(t, authn.Anonymous())

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/~bigbes/rfcs/specs/0007-storage", nil))
	assert.Equal(t, http.StatusFound, rec.Code)
	assert.Contains(t, rec.Header().Get("Location"), "meta.example")

	rec = httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/~bigbes/rfcs/specs/0007-storage.md", nil))
	assert.Equal(t, http.StatusUnauthorized, rec.Code)
}