Files
vault-plugin-secrets-arrstack/path_credentials_test.go
T
unkin-agent 3418cfd8f6
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Scaffold vault-plugin-secrets-arrstack engine
Mint dynamic arrproxy machine tokens via arrproxy's bearer-gated admin API
so Terraform-driven *arr onboarding can issue and revoke per-role tokens
non-interactively.

- Add backend, config, roles, creds paths and the arrstack_token secret
- Call POST/DELETE /api/admin/tokens with a vault:arrstack:<role> subject
- Enforce apps as a non-empty subset of sonarr/radarr/prowlarr
- Cap lease renewal at the arrproxy token's fixed expiry
- Add table-driven unit tests against a fake arrproxy admin server
- Add Makefile, nfpm packaging, and pre-commit/build/test/release pipelines
2026-08-18 21:52:07 +10:00

215 lines
6.3 KiB
Go

package arrstack
import (
"context"
"strings"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
)
func createRole(t *testing.T, b *arrstackBackend, s logical.Storage, name string, data map[string]interface{}) {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/" + name,
Storage: s,
Data: data,
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("create role %s: err=%v resp=%v", name, err, resp)
}
}
func TestCredentials_MintAndRevoke(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "media", map[string]interface{}{
"apps": "sonarr,radarr",
"ttl": "1h",
"max_ttl": "24h",
})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
if resp.Secret == nil {
t.Fatal("expected a secret in the response")
}
token, _ := resp.Data["token"].(string)
if token == "" {
t.Fatal("expected a non-empty token in response data")
}
if resp.Data["id"].(string) == "" {
t.Fatal("expected a non-empty id in response data")
}
if resp.Secret.TTL != time.Hour {
t.Fatalf("expected TTL 1h, got %s", resp.Secret.TTL)
}
if !resp.Secret.Renewable {
t.Fatal("expected the lease to be renewable")
}
if m.tokenCount() != 1 {
t.Fatalf("expected 1 token on server, got %d", m.tokenCount())
}
// The subject carries the required prefix and the role name.
if m.lastRequest.Subject != "vault:arrstack:media" {
t.Fatalf("expected subject vault:arrstack:media, got %q", m.lastRequest.Subject)
}
// The role's apps (sorted) were forwarded.
if strings.Join(m.lastRequest.Apps, ",") != "radarr,sonarr" {
t.Fatalf("expected apps radarr,sonarr forwarded, got %v", m.lastRequest.Apps)
}
// ttl_seconds is derived from the effective initial lease TTL.
if m.lastRequest.TTLSeconds != 3600 {
t.Fatalf("expected ttl_seconds 3600, got %d", m.lastRequest.TTLSeconds)
}
// The label is prefixed for attribution.
if !strings.HasPrefix(m.lastRequest.Label, "vault-media-") {
t.Fatalf("expected label prefixed vault-media-, got %q", m.lastRequest.Label)
}
revokeReq := &logical.Request{
Operation: logical.RevokeOperation,
Path: "creds/media",
Storage: s,
Secret: resp.Secret,
}
if _, err := b.HandleRequest(ctx, revokeReq); err != nil {
t.Fatalf("revoke: %v", err)
}
if m.tokenCount() != 0 {
t.Fatalf("expected token disabled on revoke, got %d active", m.tokenCount())
}
}
func TestCredentials_UnknownRole(t *testing.T) {
b, s := getTestBackend(t)
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/nope",
Storage: s,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error response for an unknown role")
}
}
func TestCredentials_NotConfigured(t *testing.T) {
b, s := getTestBackend(t)
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h"})
_, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err == nil {
t.Fatal("expected an error when backend is not configured")
}
}
func TestCredentials_TTLClampedToMaxTTL(t *testing.T) {
b, s := getTestBackend(t)
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "short", map[string]interface{}{"apps": "prowlarr", "max_ttl": "2h"})
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/short",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
if resp.Secret.TTL <= 0 || resp.Secret.TTL > 2*time.Hour {
t.Fatalf("expected TTL within (0, 2h], got %s", resp.Secret.TTL)
}
}
func TestCredentials_RenewCappedAtTokenExpiry(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h", "max_ttl": "24h"})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
renewResp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.RenewOperation,
Path: "creds/media",
Storage: s,
Secret: resp.Secret,
})
if err != nil {
t.Fatalf("renew: %v", err)
}
if renewResp == nil || renewResp.Secret == nil {
t.Fatal("expected a secret in the renew response")
}
// The token's fixed expiry is ~1h out, so the renewed lease can never
// exceed that remaining window even though max_ttl is 24h.
if renewResp.Secret.TTL <= 0 || renewResp.Secret.TTL > time.Hour {
t.Fatalf("expected renewed TTL capped within (0, 1h], got %s", renewResp.Secret.TTL)
}
}
func TestCredentials_RenewExpiredTokenNotExtended(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h", "max_ttl": "24h"})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
// Simulate the arrproxy token having already reached its fixed expiry.
resp.Secret.InternalData["expires_at"] = time.Now().Add(-time.Minute).Format(time.RFC3339)
renewResp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.RenewOperation,
Path: "creds/media",
Storage: s,
Secret: resp.Secret,
})
if err != nil {
t.Fatalf("renew: %v", err)
}
if renewResp.Secret.TTL != 0 {
t.Fatalf("expected an expired token not to be extended, got TTL %s", renewResp.Secret.TTL)
}
}