Skip to content
quarry Artifact [444fc561a0]
Login

Artifact [444fc561a0]

Artifact 444fc561a0570a127fa6e378b4684c7955df452c305563c8f33e6e073efb2619:


package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"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)
	}
	// A forge-scope op, and the identity op a repository-limited token
	// may still reach.
	for _, op := range []struct {
		name, route string
		kind        ops.Kind
	}{
		{"door.fleet", "POST /api/v1/door-fleet", ops.Command},
		{"me.account", "GET /api/v1/me", ops.Query},
		{"repo.add", "POST /api/v1/repos", ops.Command},
	} {
		if err := ops.Register(r, ops.Op[stubIn, struct{}]{
			Name: op.name, Kind: op.kind, Auth: ops.Token,
			Route: op.route, Doc: "test double: forge scope",
			Run: func(context.Context, stubIn) (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)
	}
}

// A token with a list of repositories does nothing at forge scope but
// read its own identity; a token without a list is unaffected.
func TestARepoLimitedTokenIsRefusedAtForgeScope(t *testing.T) {
	g := newDoorRig(t)
	limited, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI, oltp.ScopeWeb), Repos: []string{"vault"}})
	open, _ := g.mint(t, oltp.TokenSpec{Scopes: scopes(oltp.ScopeAPI)})
	call := func(method, path, pass string) int {
		req := httptest.NewRequest(method, path, nil)
		req.SetBasicAuth("jan", pass)
		w := httptest.NewRecorder()
		g.api.ServeHTTP(w, req)
		return w.Code
	}
	if code := call("POST", "/api/v1/door-fleet", limited); code != http.StatusForbidden {
		t.Errorf("FAIL: a repo-limited token at a forge-scope op: %d, want 403", code)
	} else {
		t.Log("ok: a repo-limited token is refused at forge scope")
	}
	// A body naming the token's own repository does not make a
	// forge-scope op about it: repo.add name=vault is a new repository.
	req := httptest.NewRequest("POST", "/api/v1/repos", strings.NewReader(`{"name":"vault"}`))
	req.Header.Set("Content-Type", "application/json")
	req.SetBasicAuth("jan", limited)
	w := httptest.NewRecorder()
	g.api.ServeHTTP(w, req)
	if w.Code != http.StatusForbidden {
		t.Errorf("FAIL: repo.add name=vault by a [vault]-limited token: %d, want 403", w.Code)
	} else {
		t.Log("ok: a body naming the token's repository does not open forge scope")
	}
	form := httptest.NewRequest("POST", "/api/v1/repos", strings.NewReader("name=vault"))
	form.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	form.SetBasicAuth("jan", limited)
	w = httptest.NewRecorder()
	g.api.ServeHTTP(w, form)
	if w.Code != http.StatusForbidden {
		t.Errorf("FAIL: repo.add form name=vault by a [vault]-limited token: %d, want 403", w.Code)
	}
	if code := call("GET", "/api/v1/me", limited); code != http.StatusOK {
		t.Errorf("FAIL: a repo-limited token reading its own identity: %d", code)
	}
	if code := call("POST", "/api/v1/r/vault/door-test", limited); code != http.StatusOK {
		t.Errorf("FAIL: a repo-limited token on its own repository: %d", code)
	}
	if code := call("POST", "/api/v1/door-fleet", open); code != http.StatusOK {
		t.Errorf("FAIL: an unlimited token at a forge-scope op: %d", code)
	}
	webVerify := g.doors.verify(oltp.ScopeWeb, func(string, string, string, ops.Auth) api.Decision { return api.Denied })
	if webVerify("", "jan", limited, ops.Token) == api.Allowed {
		t.Errorf("FAIL: a repo-limited web token passed a forge-scope page")
	}
}

// stubIn is the forge-scope doubles' input: a name, as repo.add's has.
type stubIn struct {
	Name string `json:"name" validate:"max=64"`
}