package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/web"
)
// doorRig is the forge's credential doors over a real store: one
// private repository, one account holding o and i on it.
type doorRig struct {
rg *registrar
doors *tokenDoors
api http.Handler
}
const doorPassword = "pw-jan-12345"
func newDoorRig(t *testing.T) doorRig {
t.Helper()
rg := newTestRegistrar(t, 25)
db := rg.odb
if err := db.AddAccount("jan", doorPassword, false); err != nil {
t.Fatal(err)
}
for _, repo := range []string{"vault", "other"} {
if err := db.AddRepo(repo, t.TempDir(), "", oltp.Private); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("jan", repo, "oi"); err != nil {
t.Fatal(err)
}
}
rg.refreshPrivate()
doors := &tokenDoors{db: db, limit: web.NewLoginLimiter()}
rg.doors = doors
byPassword := func(repo, user, pass string, need ops.Auth) api.Decision {
if _, ok := db.Authenticate(user, pass); !ok {
return api.Denied
}
return api.Allowed
}
// One op that needs the check-in letter on the repository it names.
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[struct{}, struct{}]{
Name: "door.test", Caps: "i", Scope: ops.ScopeRepo, Kind: ops.Command, Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/door-test", Doc: "test double: needs i",
Run: func(context.Context, struct{}) (struct{}, error) { return struct{}{}, nil },
}); err != nil {
t.Fatal(err)
}
h, err := api.RouterWithCaps(r, "test", func(account, repo string) api.CapSet { return db.EffectiveCaps(account, repo) },
nil, doors.verify(oltp.ScopeAPI, byPassword))
if err != nil {
t.Fatal(err)
}
return doorRig{rg: rg, doors: doors, api: doors.wrap(h)}
}
func (g doorRig) mint(t *testing.T, spec oltp.TokenSpec) (string, oltp.Token) {
t.Helper()
if spec.Name == "" {
spec.Name = "t"
}
v, tok, err := g.rg.odb.MintToken("jan", spec)
if err != nil {
t.Fatal(err)
}
return v, tok
}
func (g doorRig) callAPI(repo, user, pass string) int {
req := httptest.NewRequest(http.MethodPost, "/api/v1/r/"+repo+"/door-test", nil)
req.SetBasicAuth(user, pass)
w := httptest.NewRecorder()
g.api.ServeHTTP(w, req)
return w.Code
}
func basicReq(user, pass string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/r/vault/xfer", nil)
req.SetBasicAuth(user, pass)
return req
}
func scopes(s ...string) []string { return s }
// A token works only at the doors its scope allows, and only on the
// repositories it names; a refusal is the same status a wrong password
// gets.
func TestATokenOpensOnlyItsScopesDoors(t *testing.T) {
g := newDoorRig(t)
apiTok, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI)})
webTok, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeWeb)})
syncTok, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeSync)})
narrow, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI, oltp.ScopeSync), Repos: []string{"other"}})
wrong := g.callAPI("vault", "jan", "not-the-password")
webVerify := g.doors.verify(oltp.ScopeWeb, func(string, string, string, ops.Auth) api.Decision { return api.Denied })
for _, tc := range []struct {
name string
value string
api, web bool
syncBasic bool
}{
{"api", apiTok, true, false, false},
{"web", webTok, false, true, false},
{"sync", syncTok, false, false, true},
{"api+sync naming another repository", narrow, false, false, false},
} {
code := g.callAPI("vault", "jan", tc.value)
if got := code == http.StatusOK; got != tc.api {
t.Errorf("FAIL: %s token at the API door: %d", tc.name, code)
} else if !tc.api && code != wrong {
t.Errorf("FAIL: %s token refused %d at the API door, a wrong password %d", tc.name, code, wrong)
} else {
t.Logf("ok: %s token at the API door: %d", tc.name, code)
}
if got := webVerify("vault", "jan", tc.value, ops.Token) == api.Allowed; got != tc.web {
t.Errorf("FAIL: %s token at the web door: allowed=%v", tc.name, got)
}
if got := g.rg.SyncReadGate(basicReq("jan", tc.value), "vault", ""); got != tc.syncBasic {
t.Errorf("FAIL: %s token at the sync lane's Basic round: read=%v", tc.name, got)
}
}
// The narrow token does reach the repository it names.
if code := g.callAPI("other", "jan", narrow); code != http.StatusOK {
t.Errorf("FAIL: a token naming other refused there: %d", code)
}
if !g.rg.SyncReadGate(basicReq("jan", narrow), "other", "") {
t.Errorf("FAIL: a sync token naming other cannot read it")
}
// Passwords keep their doors.
if code := g.callAPI("vault", "jan", doorPassword); code != http.StatusOK {
t.Errorf("FAIL: the password at the API door: %d", code)
}
}
// Expiry and revocation take effect on the next request at every door.
func TestExpiryAndRevokeEndATokenAtEveryDoor(t *testing.T) {
g := newDoorRig(t)
value, tok := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI, oltp.ScopeSync)})
if code := g.callAPI("vault", "jan", value); code != http.StatusOK {
t.Fatalf("FAIL: a fresh token refused: %d", code)
}
if err := g.rg.odb.RevokeToken("jan", tok.ID); err != nil {
t.Fatal(err)
}
if code := g.callAPI("vault", "jan", value); code != http.StatusUnauthorized {
t.Errorf("FAIL: a revoked token at the API door: %d", code)
}
if g.rg.SyncReadGate(basicReq("jan", value), "vault", "") {
t.Errorf("FAIL: a revoked token still reads over sync")
}
short, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI, oltp.ScopeSync), Expires: time.Now().Add(2 * time.Second)})
if code := g.callAPI("vault", "jan", short); code != http.StatusOK {
t.Fatalf("FAIL: a live short token refused: %d", code)
}
time.Sleep(2100 * time.Millisecond)
if code := g.callAPI("vault", "jan", short); code != http.StatusUnauthorized {
t.Errorf("FAIL: an expired token at the API door: %d", code)
}
if g.rg.SyncReadGate(basicReq("jan", short), "vault", "") {
t.Errorf("FAIL: an expired token still reads over sync")
}
}
// The ceiling caps the letters the router and the read gate see: a token
// ceilinged to o reads but cannot do what needs i, and one ceilinged to
// i on a private repository holds no read letter beyond what i implies.
func TestTheCeilingCapsLettersAtTheDoors(t *testing.T) {
g := newDoorRig(t)
readOnly, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI, oltp.ScopeSync), Ceiling: "o"})
if code := g.callAPI("vault", "jan", readOnly); code != http.StatusForbidden {
t.Errorf("FAIL: a token ceilinged to o reached an op needing i: %d", code)
}
if !g.rg.SyncReadGate(basicReq("jan", readOnly), "vault", "") {
t.Errorf("FAIL: a token ceilinged to o cannot read")
}
upload, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeSync), Ceiling: "y"})
if g.rg.SyncReadGate(basicReq("jan", upload), "vault", "") {
t.Errorf("FAIL: a token ceilinged to y reads a private repository the public cannot")
}
}
// A refused token is a miss on the sign-in limiter: five buy the same
// hold five wrong passwords at the sign-in form buy, at the API door and
// the sync lane alike, and a held account's good token is held too.
func TestTokenMissesFeedTheLoginLimiter(t *testing.T) {
g := newDoorRig(t)
good, tok := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI, oltp.ScopeSync)})
bad := "qat_" + tok.ID + "_WRONGWRONGWRONGWRONGWRONGWRONGWR"
for i := 0; i < 3; i++ {
g.callAPI("vault", "jan", bad)
}
for i := 0; i < 3; i++ {
g.rg.SyncReadGate(basicReq("jan", bad), "vault", "")
}
if code := g.callAPI("vault", "jan", good); code != http.StatusTooManyRequests {
t.Errorf("FAIL: after six token misses the API door answered %d, want 429", code)
} else {
t.Log("ok: six token misses across two doors hold the account")
}
if g.rg.SyncReadGate(basicReq("jan", good), "vault", "") {
t.Errorf("FAIL: a held account's good token still reads over sync")
}
// A success clears it: here, the limiter's own clear, as the sign-in
// form's success would.
g.doors.limit.Succeeded("jan", "192.0.2.1")
if code := g.callAPI("vault", "jan", good); code != http.StatusOK {
t.Errorf("FAIL: after the slate cleared the good token answered %d", code)
}
}