package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/ops"
)
// mintRecorder answers the engine surface and remembers what a mint was
// actually asked for, which is the only way to tell a token scoped as
// requested from one scoped to something else.
type mintRecorder struct {
mu sync.Mutex
minted int
prefixes []string
groups []string
}
func (m *mintRecorder) RunDetail(string) (ci.Run, error) { return ci.Run{}, nil }
func (m *mintRecorder) StepLogs(string, string, int, bool) ([]ci.LogChunk, error) {
return nil, nil
}
func (m *mintRecorder) Workers() ([]ci.Worker, error) { return nil, nil }
func (m *mintRecorder) WorkerTokens() ([]ci.WorkerToken, error) { return nil, nil }
func (m *mintRecorder) RevokeWorkerToken(string) error { return nil }
func (m *mintRecorder) MintWorkerToken(_ string, prefixes, groups []string) (ci.WorkerToken, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.minted++
m.prefixes, m.groups = prefixes, groups
return ci.WorkerToken{ID: "tok1", Token: "s"}, nil
}
func scopeServer(t *testing.T, eng *mintRecorder, letters string) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {}})}); err != nil {
t.Fatal(err)
}
// A Verify must be present or mayCap short-circuits to true and no
// capability is checked at all -- which is exactly how this test
// first passed against the unfixed code.
verify := Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
WithCaps(stubCaps{letters: letters}),
WithEngine(eng),
}, verify))
t.Cleanup(srv.Close)
return srv
}
// A scope the caller may not set is REFUSED, not dropped.
//
// The capability check and the field check were one condition, so a
// repository admin who posted `prefixes` had them silently ignored and
// got a group-scoped token back. Nothing said the field went nowhere.
// jva hit it with a stale form and read the three tokens as correctly
// scoped -- which they were, just not scoped to what was asked for
// (2c164754).
//
// Asserted on what reached the ENGINE, not on the status code alone: a
// refusal that still minted something would be the same bug wearing an
// error message.
func TestAScopeYouMayNotSetIsRefusedNotDropped(t *testing.T) {
eng := &mintRecorder{}
// `a` and `8`, not `s`: a repository admin, which is the account
// the field is not drawn for.
srv := scopeServer(t, eng, "a8i")
resp := postAsDan(t, srv, "/r/alpha/settings/worker-tokens", url.Values{
"label": {"jva's runner"},
"prefixes": {"dantest-puzzles dantest-html"},
})
if resp.StatusCode == http.StatusSeeOther {
t.Errorf("a repository admin's task-type scope was accepted with a redirect: the "+
"field was dropped and a differently-scoped token minted (status %d)", resp.StatusCode)
}
eng.mu.Lock()
minted, prefixes := eng.minted, eng.prefixes
eng.mu.Unlock()
if minted != 0 {
t.Errorf("a token was minted anyway (%d), scoped to %v -- the caller asked for a "+
"fleet-wide credential and would have been handed a group one", minted, prefixes)
}
}
// And a site admin still mints fleet-wide, prefixes and all. Nothing
// else can: RFC 0009's fleet Runners page does not exist yet, so this
// form is the only door to an unscoped token and closing it would
// strand the forge's own workers.
func TestASiteAdminStillMintsAFleetWideScope(t *testing.T) {
eng := &mintRecorder{}
// "as": stubCaps is naive and does not expand s the way a real
// CapSet does, so the site admin needs the repository letter
// spelled out to pass the route's own gate.
srv := scopeServer(t, eng, "as")
resp := postAsDan(t, srv, "/r/alpha/settings/worker-tokens", url.Values{
"label": {"forge runner"},
"prefixes": {"dantest-puzzles"},
})
if resp.StatusCode != http.StatusSeeOther {
body := make([]byte, 400)
n, _ := resp.Body.Read(body)
t.Fatalf("a site admin was refused (status %d): %s", resp.StatusCode,
strings.TrimSpace(string(body[:n])))
}
eng.mu.Lock()
defer eng.mu.Unlock()
if eng.minted != 1 {
t.Fatalf("minted %d tokens, want 1", eng.minted)
}
if len(eng.prefixes) != 1 || eng.prefixes[0] != "dantest-puzzles" {
t.Errorf("prefixes = %v, want the one asked for", eng.prefixes)
}
if len(eng.groups) != 0 {
t.Errorf("groups = %v, want none: a typed scope leaves the token ungrouped", eng.groups)
}
}
// A repository admin who posts NO scope mints a group-scoped token whose
// task-type scope is the group's own name. This test used to assert the
// scope was EMPTY — which is exactly what made every such token useless:
// the engine reads an empty scope as "no task types at all" and refuses
// every poll (found running quarry runner against runner-probe,
// f24d78d9).
func TestARepoAdminStillMintsAGroupToken(t *testing.T) {
eng := &mintRecorder{}
srv := scopeServer(t, eng, "a8i")
if resp := postAsDan(t, srv, "/r/alpha/settings/worker-tokens", url.Values{
"label": {"repo runner"},
}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("an ordinary mint was refused: status %d", resp.StatusCode)
}
eng.mu.Lock()
defer eng.mu.Unlock()
if eng.minted != 1 || len(eng.groups) != 1 || len(eng.prefixes) != 1 || eng.prefixes[0] != eng.groups[0] {
t.Errorf("minted=%d prefixes=%v groups=%v, want one token scoped to its group and the group's own task types",
eng.minted, eng.prefixes, eng.groups)
}
}