package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// credRig is a forge with a person (jva), their agent (jva-bot), a
// forge agent (fbot, answerable to dmestas) and the stores whose
// credentials an approved ask mints.
type credRig struct {
t *testing.T
db *oltp.DB
deps Deps
r *ops.Registry
h http.Handler
rec *settingRecorder
// failRevoke and failClear make the next sweep's revoke or clear
// fail, as a database error would.
failRevoke, failClear bool
}
type flakyExternal struct {
ExternalCI
rig *credRig
}
func (f flakyExternal) RevokeReporterToken(repo, hash string) error {
if f.rig.failRevoke {
return errors.New("disk I/O error")
}
return f.ExternalCI.RevokeReporterToken(repo, hash)
}
type flakySignOffs struct {
SignOffStore
rig *credRig
}
func (f flakySignOffs) ClearSignOffLapse(id string) error {
if f.rig.failClear {
return errors.New("disk I/O error")
}
return f.SignOffStore.ClearSignOffLapse(id)
}
func newCredRig(t *testing.T) *credRig {
t.Helper()
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
for _, who := range []string{"dmestas", "jva"} {
if err := db.AddAccount(who, "a-long-enough-password", who == "dmestas"); err != nil {
t.Fatal(err)
}
}
if err := db.AddAccount("jva-bot", "a-long-enough-password", false); err != nil {
t.Fatal(err)
}
if err := db.SetOperator("jva-bot", "jva"); err != nil {
t.Fatal(err)
}
if err := db.NewForgeAgent("fbot", "dmestas"); err != nil {
t.Fatal(err)
}
rec := &settingRecorder{}
holds := func(string, string) CapSet { return oltp.CapSet("a") }
c := &credRig{t: t, db: db, rec: rec}
deps := Deps{Version: "v", SettingLog: rec, SecretBox: testBox(t), Acts: db, External: flakyExternal{db, c},
Repos: NewRepos(map[string]*Repo{"test": {Index: wikiIndex(t), Code: "code-test"}}),
Accounts: adminAdapter{acctAdapter{db}}, SignOffs: flakySignOffs{testSignOffs{db}, c}, Prefs: db, CapsFor: holds}
r := ops.NewRegistry()
if err := RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
// An agent holds no letter of its own here, so ci.token.new is
// refused for it and can only be asked.
agentHolds := func(who, repo string) CapSet {
if who == "jva-bot" {
return oltp.CapSet("")
}
return holds(who, repo)
}
h, err := RouterWithCaps(r, "v", agentHolds, nil)
if err != nil {
t.Fatal(err)
}
c.deps, c.r, c.h = deps, r, h
return c
}
// ask files a request as agent over HTTP with Quarry-Ask and has its
// person approve it; the id comes back.
func (c *credRig) ask(agent, person, path, body string) string {
c.t.Helper()
ctx := ops.WithToken(ops.WithUser(context.Background(), agent), ops.TokenAuth{ID: "T",
Caps: func(string) ops.Caps { return oltp.CapSet("") }})
req := httptest.NewRequest("POST", path, strings.NewReader(body)).WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Quarry-Ask", "for a test")
w := httptest.NewRecorder()
c.h.ServeHTTP(w, req)
var asked struct {
SignOff SignOffRequest `json:"signoff"`
}
if err := json.Unmarshal(w.Body.Bytes(), &asked); err != nil || w.Code != 202 {
c.t.Fatalf("FAIL: asking %s: %d %s", path, w.Code, w.Body)
}
c.approve(person, asked.SignOff.ID)
return asked.SignOff.ID
}
func (c *credRig) approve(person, id string) {
c.t.Helper()
if _, err := c.r.Invoke(asUser(person), "signoff.decide", signOffDecideIn{ID: id, Approve: true}); err != nil {
c.t.Fatalf("FAIL: approving %s: %v", id, err)
}
}
// held reads the sealed secret without collecting it, as the forge
// itself could.
func (c *credRig) held(id string) string {
c.t.Helper()
row, _, err := c.db.SignOffByID(id)
if err != nil || row.Secret == "" {
c.t.Fatalf("FAIL: no held secret for %s: %v", id, err)
}
v, err := c.deps.openSignOffSecret(id, row.Secret)
if err != nil {
c.t.Fatal(err)
}
return v
}
func (c *credRig) value(id string) string {
c.t.Helper()
var v struct{ Value string }
if err := json.Unmarshal([]byte(c.held(id)), &v); err != nil || v.Value == "" {
c.t.Fatalf("FAIL: the held secret: %v", err)
}
return v.Value
}
func (c *credRig) sweep(after time.Duration) int {
c.t.Helper()
n, err := c.deps.LapseSignOffSecrets(time.Now().Add(after))
if err != nil {
c.t.Fatal(err)
}
return n
}
// lapseActs is the account-level receipts the sweep wrote for account.
func (c *credRig) lapseActs(account string) []oltp.ForgeAct {
c.t.Helper()
acts, err := c.db.AccountActs(account, 50)
if err != nil {
c.t.Fatal(err)
}
var out []oltp.ForgeAct
for _, a := range acts {
if strings.Contains(a.Actor, "lapsed uncollected") {
out = append(out, a)
}
}
return out
}
func (c *credRig) settingRows(field string) []logRow {
var out []logRow
for _, r := range c.rec.rows {
if r.field == field && strings.Contains(r.now, "lapsed uncollected") {
out = append(out, r)
}
}
return out
}
const lateEnough = 25 * time.Hour
// An agent token an agent asked for and never collected is revoked
// when its secret lapses, once, with a receipt on the agent's account
// that names the request and the asker and not the value; a second
// sweep does nothing; one collected in time is never revoked.
func TestALapsedAgentTokenAskIsRevoked(t *testing.T) {
c := newCredRig(t)
body := `{"agent":"jva-bot","name":"s","scopes":"api"}`
lost := c.ask("jva-bot", "jva", "/api/v1/agents/tokens", body)
value := c.value(lost)
if _, ok := c.db.ResolveToken("jva-bot", value, oltp.ScopeAPI); !ok {
t.Fatal("FAIL: the minted token does not verify before the lapse")
}
kept := c.ask("jva-bot", "jva", "/api/v1/agents/tokens", `{"agent":"jva-bot","name":"kept","scopes":"api"}`)
got, err := c.r.Invoke(asUser("jva-bot"), "signoff.get", signOffGetIn{ID: kept})
if err != nil || len(got.(signOffGetOut).Secret) == 0 {
t.Fatalf("FAIL: collecting: %v", err)
}
var keptSecret struct{ Value string }
if err := json.Unmarshal(got.(signOffGetOut).Secret, &keptSecret); err != nil {
t.Fatal(err)
}
if n := c.sweep(0); n != 0 {
t.Fatalf("FAIL: a fresh secret lapsed: %d", n)
}
if n := c.sweep(lateEnough); n != 1 {
t.Fatalf("FAIL: the sweep cleared %d, want 1", n)
}
if _, ok := c.db.ResolveToken("jva-bot", value, oltp.ScopeAPI); ok {
t.Error("FAIL: the uncollected token still verifies")
}
if _, ok := c.db.ResolveToken("jva-bot", keptSecret.Value, oltp.ScopeAPI); !ok {
t.Error("FAIL: a collected token was revoked")
}
acts := c.lapseActs("jva-bot")
if len(acts) != 1 || !strings.Contains(acts[0].Actor, lost) || !strings.Contains(acts[0].Actor, "asked by jva-bot") {
t.Fatalf("FAIL: receipts %+v", acts)
}
for _, a := range acts {
if strings.Contains(a.Actor+a.Op, value) {
t.Error("FAIL: a receipt holds the token value")
}
}
if n := c.sweep(lateEnough + time.Hour); n != 0 || len(c.lapseActs("jva-bot")) != 1 {
t.Errorf("FAIL: a second sweep did something: %d, %+v", n, c.lapseActs("jva-bot"))
}
if !t.Failed() {
t.Log("ok")
}
}
// An agent deleted before its token's lapse is not an error and
// leaves no receipt: there is nothing left to revoke.
func TestALapseForADeletedAgentIsQuiet(t *testing.T) {
c := newCredRig(t)
id := c.ask("jva-bot", "jva", "/api/v1/agents/tokens", `{"agent":"jva-bot","name":"s","scopes":"api"}`)
due, err := c.db.DueSignOffLapses(time.Now().Add(lateEnough))
if err != nil || len(due) != 1 || due[0].ID != id || !strings.Contains(due[0].Refs, lapseAgent) || !strings.Contains(due[0].Refs, "jva-bot") {
t.Fatalf("FAIL: the references do not name an agent token: %+v %v", due, err)
}
if err := c.db.DeleteAccount("jva-bot"); err != nil {
t.Fatal(err)
}
n, err := c.deps.LapseSignOffSecrets(time.Now().Add(lateEnough))
if err != nil || n != 1 {
t.Fatalf("FAIL: the sweep cleared %d for %s: %v", n, id, err)
}
if row, _, _ := c.db.SignOffByID(id); row.Secret != oltp.SignOffSecretLapsed {
t.Errorf("FAIL: the row was not cleared: %q", row.Secret)
}
if got := c.lapseActs("jva-bot"); len(got) != 0 {
t.Errorf("FAIL: receipts for a deleted agent: %+v", got)
}
if !t.Failed() {
t.Log("ok")
}
}
// A reissue's lapse revokes only the token it minted, and its receipt
// says the agent is left with none.
func TestALapsedReissueRevokesWhatItMinted(t *testing.T) {
c := newCredRig(t)
if _, _, err := c.db.MintToken("jva-bot", oltp.TokenSpec{Name: "old", Scopes: []string{oltp.ScopeAPI}}); err != nil {
t.Fatal(err)
}
id := c.ask("jva-bot", "jva", "/api/v1/agents/tokens/reissue", `{"name":"jva-bot"}`)
value := c.value(id)
if _, ok := c.db.ResolveToken("jva-bot", value, oltp.ScopeAPI); !ok {
t.Fatal("FAIL: the reissued token does not verify")
}
if n := c.sweep(lateEnough); n != 1 {
t.Fatalf("FAIL: swept %d", n)
}
if _, ok := c.db.ResolveToken("jva-bot", value, oltp.ScopeAPI); ok {
t.Error("FAIL: the reissued token survived its lapse")
}
acts := c.lapseActs("jva-bot")
if len(acts) != 1 || !strings.Contains(acts[0].Actor, "no live token") {
t.Errorf("FAIL: receipts %+v", acts)
}
if !t.Failed() {
t.Log("ok")
}
}
// An uncollected CI reporter token is revoked, logged in the
// repository's settings log; one collected in time stays.
func TestALapsedReporterTokenAskIsRevoked(t *testing.T) {
c := newCredRig(t)
lost := c.ask("jva-bot", "jva", "/api/v1/r/test/ci/tokens", `{"label":"ci"}`)
var v struct{ Token string }
if err := json.Unmarshal([]byte(c.held(lost)), &v); err != nil || v.Token == "" {
t.Fatalf("FAIL: the held secret: %v", err)
}
kept := c.ask("jva-bot", "jva", "/api/v1/r/test/ci/tokens", `{"label":"kept"}`)
got, err := c.r.Invoke(asUser("jva-bot"), "signoff.get", signOffGetIn{ID: kept})
if err != nil {
t.Fatal(err)
}
var k struct{ Token string }
if err := json.Unmarshal(got.(signOffGetOut).Secret, &k); err != nil {
t.Fatal(err)
}
if !c.db.CheckReporterToken("test", v.Token) {
t.Fatal("FAIL: the reporter token does not verify before the lapse")
}
if n := c.sweep(lateEnough); n != 1 {
t.Fatalf("FAIL: swept %d", n)
}
if c.db.CheckReporterToken("test", v.Token) {
t.Error("FAIL: the uncollected reporter token still verifies")
}
if !c.db.CheckReporterToken("test", k.Token) {
t.Error("FAIL: a collected reporter token was revoked")
}
rows := c.settingRows("reporter-token")
if len(rows) != 1 || !strings.Contains(rows[0].now, lost) || strings.Contains(rows[0].was+rows[0].now, v.Token) {
t.Errorf("FAIL: settings rows %+v", rows)
}
if n := c.sweep(lateEnough + time.Hour); n != 0 || len(c.settingRows("reporter-token")) != 1 {
t.Errorf("FAIL: a second sweep did something: %d", n)
}
if !t.Failed() {
t.Log("ok")
}
}
// A plan that minted several credentials has every one revoked.
func TestALapsedPlanRevokesEveryCredentialItMinted(t *testing.T) {
c := newCredRig(t)
step := func(op, in string) planStepIn { return planStepIn{Op: op, Input: json.RawMessage(in)} }
out, err := c.r.Invoke(asUser("fbot"), planOp, planIn{Title: "credentials", Steps: []planStepIn{
step("agent.token.new", `{"agent":"fbot","name":"a","scopes":"api"}`),
step("agent.token.new", `{"agent":"fbot","name":"b","scopes":"api"}`),
step("ci.token.new", `{"repo":"test","label":"r"}`),
}})
if err != nil {
t.Fatalf("FAIL: asking: %v", err)
}
id := out.(planOut).SignOff.ID
c.approve("dmestas", id)
var secrets map[string]json.RawMessage
if err := json.Unmarshal([]byte(c.held(id)), &secrets); err != nil || len(secrets) != 3 {
t.Fatalf("FAIL: the plan's secrets: %v %d", err, len(secrets))
}
var a, b struct{ Value string }
var rep struct{ Token string }
if json.Unmarshal(secrets["1"], &a) != nil || json.Unmarshal(secrets["2"], &b) != nil || json.Unmarshal(secrets["3"], &rep) != nil {
t.Fatal("FAIL: reading the plan's secrets")
}
live := func() (bool, bool, bool) {
_, x := c.db.ResolveToken("fbot", a.Value, oltp.ScopeAPI)
_, y := c.db.ResolveToken("fbot", b.Value, oltp.ScopeAPI)
return x, y, c.db.CheckReporterToken("test", rep.Token)
}
if x, y, z := live(); !x || !y || !z {
t.Fatalf("FAIL: before the lapse: %v %v %v", x, y, z)
}
if n := c.sweep(lateEnough); n != 1 {
t.Fatalf("FAIL: swept %d", n)
}
if x, y, z := live(); x || y || z {
t.Errorf("FAIL: after the lapse still live: %v %v %v", x, y, z)
}
if n := len(c.lapseActs("fbot")); n != 2 {
t.Errorf("FAIL: %d account receipts, want 2", n)
}
if n := len(c.settingRows("reporter-token")); n != 1 {
t.Errorf("FAIL: %d settings rows, want 1", n)
}
if !t.Failed() {
t.Log("ok")
}
}
// A revoke that fails leaves the secret and its reference in place and
// says so once; the next sweep finishes the job with one log row.
func TestAFailedRevokeIsRetriedByTheNextSweep(t *testing.T) {
c := newCredRig(t)
id := c.ask("jva-bot", "jva", "/api/v1/r/test/ci/tokens", `{"label":"ci"}`)
var v struct{ Token string }
if err := json.Unmarshal([]byte(c.held(id)), &v); err != nil || v.Token == "" {
t.Fatalf("FAIL: the held secret: %v", err)
}
c.failRevoke = true
n, err := c.deps.LapseSignOffSecrets(time.Now().Add(lateEnough))
if err == nil || n != 0 {
t.Fatalf("FAIL: a failed revoke answered %d, %v", n, err)
}
if strings.Contains(err.Error(), v.Token) {
t.Error("FAIL: the failure names the token value")
}
// The row is lapsing: refused to a collector, references kept.
got, gerr := c.r.Invoke(asUser("jva-bot"), "signoff.get", signOffGetIn{ID: id})
if gerr != nil || len(got.(signOffGetOut).Secret) != 0 || got.(signOffGetOut).Note == "" {
t.Errorf("FAIL: a lapsing row was handed to its collector: %v %+v", gerr, got)
}
if due, _ := c.db.DueSignOffLapses(time.Now()); len(due) != 1 || due[0].Refs == "" {
t.Errorf("FAIL: the reference is gone: %+v", due)
}
if !c.db.CheckReporterToken("test", v.Token) || len(c.settingRows("reporter-token")) != 0 {
t.Error("FAIL: a failed revoke changed something")
}
c.failRevoke = false
if n := c.sweep(lateEnough); n != 1 {
t.Fatalf("FAIL: the retry cleared %d", n)
}
if c.db.CheckReporterToken("test", v.Token) {
t.Error("FAIL: the token survived the retry")
}
if n := len(c.settingRows("reporter-token")); n != 1 {
t.Errorf("FAIL: %d log rows after the retry, want 1", n)
}
if !t.Failed() {
t.Log("ok")
}
}
// A clear that fails after a successful revoke is retried quietly: the
// token ends revoked, the row cleared, and exactly one log row.
func TestAFailedClearIsRetriedWithoutASecondLogRow(t *testing.T) {
c := newCredRig(t)
id := c.ask("jva-bot", "jva", "/api/v1/agents/tokens", `{"agent":"jva-bot","name":"s","scopes":"api"}`)
value := c.value(id)
c.failClear = true
if n, err := c.deps.LapseSignOffSecrets(time.Now().Add(lateEnough)); err == nil || n != 0 {
t.Fatalf("FAIL: a failed clear answered %d, %v", n, err)
}
if _, ok := c.db.ResolveToken("jva-bot", value, oltp.ScopeAPI); ok {
t.Error("FAIL: the token was not revoked before the clear")
}
c.failClear = false
if n := c.sweep(lateEnough); n != 1 {
t.Fatalf("FAIL: the retry cleared %d", n)
}
row, _, _ := c.db.SignOffByID(id)
if row.Secret != oltp.SignOffSecretLapsed {
t.Errorf("FAIL: the secret column holds %q", row.Secret)
}
if n := len(c.lapseActs("jva-bot")); n != 1 {
t.Errorf("FAIL: %d receipts, want 1", n)
}
if n := c.sweep(lateEnough + time.Hour); n != 0 {
t.Errorf("FAIL: a further sweep cleared %d", n)
}
if !t.Failed() {
t.Log("ok")
}
}