Files
vault-plugin-secrets-gitea/backend_test.go
T
unkinben 20613afb26 Initial vault-plugin-secrets-gitea engine
Add a Vault/OpenBao secrets engine that mints ephemeral, scoped Gitea
access tokens on demand. The engine holds a single seeded Gitea site-admin
Basic-Auth credential and, per role, mints a fresh per-user token via the
admin API, bound to a Vault lease and deleted from Gitea on revocation.
Gitea requires Basic Auth for token management (token auth is rejected),
and reqSelfOrAdmin lets a site admin manage any user's tokens, which is the
mechanism this relies on. Gitea tokens never expire server-side, so the
Vault lease is the sole expiry mechanism.

- add backend wiring, config (+ rotate-root), roles, creds paths
- add the gitea client (Basic Auth create/delete token, admin password change)
- add scope validation against Gitea's access-token scope set
- add unit tests (fake Gitea API) and a Vault+OpenBao e2e harness
- add Makefile, nfpm RPM packaging, and Woodpecker build/test/release pipelines

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 00:54:59 +10:00

450 lines
13 KiB
Go

package gitea
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
// fakeGitea is an in-memory stand-in for the Gitea REST API covering just the
// endpoints the plugin uses: token create/delete, admin password change, and
// whoami. Basic Auth is validated against the *current* admin credentials, so
// tests exercise password rotation exactly as production does.
type fakeGitea struct {
mu sync.Mutex
adminU string
adminP string
nextID int64
tokens map[string]storedToken // key: username/id
minted int
adminHit int
}
type storedToken struct {
id int64
name string
sha1 string
scopes []string
username string
}
func newFakeGitea(adminUser, adminPass string) *fakeGitea {
return &fakeGitea{
adminU: adminUser,
adminP: adminPass,
tokens: map[string]storedToken{},
}
}
func key(username, id string) string { return username + "/" + id }
func (f *fakeGitea) server(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
f.mu.Lock()
validAdmin := ok && u == f.adminU && p == f.adminP
f.mu.Unlock()
if !validAdmin {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/user":
writeJSON(w, http.StatusOK, map[string]interface{}{"login": u, "is_admin": true})
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/users/") && strings.HasSuffix(r.URL.Path, "/tokens"):
username := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/users/"), "/tokens")
var in createTokenOption
_ = json.NewDecoder(r.Body).Decode(&in)
f.mu.Lock()
f.nextID++
id := f.nextID
f.minted++
sha := "sha1-" + strconv.FormatInt(id, 10)
f.tokens[key(username, strconv.FormatInt(id, 10))] = storedToken{
id: id, name: in.Name, sha1: sha, scopes: in.Scopes, username: username,
}
f.mu.Unlock()
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": id, "name": in.Name, "sha1": sha, "token_last_eight": "lasteig8", "scopes": in.Scopes,
})
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/tokens/"):
// /api/v1/users/{username}/tokens/{id}
rest := strings.TrimPrefix(r.URL.Path, "/api/v1/users/")
parts := strings.SplitN(rest, "/tokens/", 2)
if len(parts) != 2 {
w.WriteHeader(http.StatusNotFound)
return
}
f.mu.Lock()
k := key(parts[0], parts[1])
_, exists := f.tokens[k]
if exists {
delete(f.tokens, k)
}
f.mu.Unlock()
if !exists {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/admin/users/"):
var in editUserOption
_ = json.NewDecoder(r.Body).Decode(&in)
f.mu.Lock()
f.adminHit++
if in.Password != "" {
f.adminP = in.Password
}
f.mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func (f *fakeGitea) has(username, id string) bool {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.tokens[key(username, id)]
return ok
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func newTestBackend(t *testing.T) (*giteaBackend, logical.Storage) {
t.Helper()
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
b, err := Factory(context.Background(), config)
if err != nil {
t.Fatalf("Factory: %v", err)
}
return b.(*giteaBackend), config.StorageView
}
func req(t *testing.T, b *giteaBackend, s logical.Storage, op logical.Operation, path string, data map[string]interface{}) *logical.Response {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: op,
Path: path,
Data: data,
Storage: s,
})
if err != nil {
t.Fatalf("%s %s: %v", op, path, err)
}
if resp != nil && resp.IsError() {
t.Fatalf("%s %s: %v", op, path, resp.Error())
}
return resp
}
func configure(t *testing.T, b *giteaBackend, s logical.Storage, url string) {
t.Helper()
req(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
"gitea_url": url,
"admin_username": "bot-admin",
"admin_password": "seed-password",
})
}
func TestLifecycle(t *testing.T) {
fake := newFakeGitea("bot-admin", "seed-password")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
configure(t, b, s, srv.URL)
req(t, b, s, logical.CreateOperation, "roles/teabot", map[string]interface{}{
"username": "teabot",
"scopes": "read:repository,write:issue",
"ttl": "1h",
"max_ttl": "24h",
})
creds := req(t, b, s, logical.ReadOperation, "creds/teabot", nil)
if creds.Secret == nil {
t.Fatal("creds returned no secret")
}
tokenID, _ := creds.Data["token_id"].(string)
tokenVal, _ := creds.Data["token"].(string)
username, _ := creds.Data["username"].(string)
if tokenID == "" || tokenVal == "" {
t.Fatalf("creds missing token/token_id: %#v", creds.Data)
}
if username != "teabot" {
t.Errorf("username = %q, want teabot", username)
}
if creds.Data["gitea_url"].(string) != srv.URL {
t.Errorf("gitea_url = %q, want %q", creds.Data["gitea_url"], srv.URL)
}
if !fake.has("teabot", tokenID) {
t.Error("minted token not present in gitea")
}
// Renew keeps the same secret.
if _, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.RenewOperation,
Secret: creds.Secret,
Storage: s,
}); err != nil {
t.Fatalf("renew: %v", err)
}
// Revoke deletes the token from gitea.
if _, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.RevokeOperation,
Secret: creds.Secret,
Storage: s,
}); err != nil {
t.Fatalf("revoke: %v", err)
}
if fake.has("teabot", tokenID) {
t.Error("token still present after revoke")
}
}
func TestRevokeIsIdempotent(t *testing.T) {
fake := newFakeGitea("bot-admin", "seed-password")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
configure(t, b, s, srv.URL)
req(t, b, s, logical.CreateOperation, "roles/teabot", map[string]interface{}{
"username": "teabot", "scopes": "read:repository",
})
creds := req(t, b, s, logical.ReadOperation, "creds/teabot", nil)
revoke := func() error {
_, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.RevokeOperation,
Secret: creds.Secret,
Storage: s,
})
return err
}
if err := revoke(); err != nil {
t.Fatalf("first revoke: %v", err)
}
// A second revoke (token already gone → 404) must still succeed.
if err := revoke(); err != nil {
t.Fatalf("second revoke should be idempotent, got: %v", err)
}
}
func TestScopeValidation(t *testing.T) {
fake := newFakeGitea("bot-admin", "seed-password")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
configure(t, b, s, srv.URL)
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Data: map[string]interface{}{"username": "teabot", "scopes": "read:repository,bogus:scope"},
Storage: s,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error for invalid scope")
}
// Duplicate + mixed-case scopes normalise down cleanly.
req(t, b, s, logical.CreateOperation, "roles/ok", map[string]interface{}{
"username": "teabot", "scopes": "Read:Repository, read:repository ,write:issue",
})
role := req(t, b, s, logical.ReadOperation, "roles/ok", nil)
got, _ := role.Data["scopes"].([]string)
if len(got) != 2 || got[0] != "read:repository" || got[1] != "write:issue" {
t.Errorf("normalised scopes = %v, want [read:repository write:issue]", got)
}
}
func TestRoleRequiresUsernameAndScopes(t *testing.T) {
fake := newFakeGitea("bot-admin", "seed-password")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
configure(t, b, s, srv.URL)
// Missing scopes.
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/nos",
Data: map[string]interface{}{"username": "teabot"},
Storage: s,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error for role without scopes")
}
}
func TestConfigRequiresAdminAndVerifies(t *testing.T) {
fake := newFakeGitea("bot-admin", "seed-password")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
// Missing admin_password.
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Data: map[string]interface{}{"gitea_url": srv.URL, "admin_username": "bot-admin"},
Storage: s,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error when admin_password missing")
}
// Wrong password fails verification against the fake (401).
resp, err = b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Data: map[string]interface{}{"gitea_url": srv.URL, "admin_username": "bot-admin", "admin_password": "wrong"},
Storage: s,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error when admin credentials fail verification")
}
// config read must never leak the password.
configure(t, b, s, srv.URL)
read := req(t, b, s, logical.ReadOperation, "config", nil)
if _, leaked := read.Data["admin_password"]; leaked {
t.Fatal("config read leaked admin_password")
}
}
func TestCredsBeforeConfig(t *testing.T) {
b, s := newTestBackend(t)
// Roles can be written without config, but minting from one before config is
// written must fail with the not-configured error.
req(t, b, s, logical.CreateOperation, "roles/x", map[string]interface{}{
"username": "teabot", "scopes": "read:repository",
})
_, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/x",
Storage: s,
})
if err == nil {
t.Fatal("expected creds read to fail without config")
}
}
func TestRotateRoot(t *testing.T) {
fake := newFakeGitea("bot-admin", "seed-password")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
configure(t, b, s, srv.URL)
// Rotate the root password.
rot := req(t, b, s, logical.UpdateOperation, "config/rotate-root", nil)
if ok, _ := rot.Data["rotated"].(bool); !ok {
t.Fatal("rotate-root did not report success")
}
// The fake now only accepts the new password; the engine's stored config must
// have been updated to match, so minting still works after rotation.
req(t, b, s, logical.CreateOperation, "roles/teabot", map[string]interface{}{
"username": "teabot", "scopes": "read:repository",
})
creds := req(t, b, s, logical.ReadOperation, "creds/teabot", nil)
if creds.Secret == nil {
t.Fatal("mint after rotate-root failed")
}
// The stored password must no longer be the seed.
cfg, err := getConfig(context.Background(), s)
if err != nil {
t.Fatalf("getConfig: %v", err)
}
if cfg.AdminPassword == "seed-password" {
t.Error("admin_password was not changed by rotate-root")
}
if fake.adminHit == 0 {
t.Error("rotate-root did not call the gitea admin API")
}
}
func TestRotateRootRollback(t *testing.T) {
fake := newFakeGitea("bot-admin", "seed-password")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
configure(t, b, s, srv.URL)
// Force the storage write to fail so rotate-root must roll back.
failing := &failingStorage{Storage: s, failPut: true}
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: failing,
})
if err == nil && (resp == nil || !resp.IsError()) {
t.Fatal("expected rotate-root to fail when storage write fails")
}
// After rollback the seed password must work again: a normal config write
// (which verifies against gitea) should succeed with the original password.
configure(t, b, s, srv.URL)
}
// failingStorage wraps a storage and can be told to fail Put, to exercise the
// rotate-root rollback path.
type failingStorage struct {
logical.Storage
failPut bool
}
func (f *failingStorage) Put(ctx context.Context, entry *logical.StorageEntry) error {
if f.failPut && entry.Key == configStoragePath {
return errForcedPutFailure
}
return f.Storage.Put(ctx, entry)
}
var errForcedPutFailure = &forcedError{"forced put failure"}
type forcedError struct{ msg string }
func (e *forcedError) Error() string { return e.msg }