Feat v0.7.7 api tokens ext permissions (#61)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-04-02 19:11:47 +00:00
committed by xcaliber
parent e02b13dc12
commit e4f0bdbd36
34 changed files with 1769 additions and 58 deletions

View File

@@ -0,0 +1,267 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"armature/auth"
"armature/models"
"armature/store"
)
// ── Token Generation Tests ──────────────────
func TestGenerateToken_Format(t *testing.T) {
raw, hash, prefix := generateToken()
if !strings.HasPrefix(raw, "arm_pat_") {
t.Errorf("expected arm_pat_ prefix, got %q", raw[:8])
}
// arm_pat_ (8) + 64 hex chars = 72
if len(raw) != 72 {
t.Errorf("expected 72 chars, got %d", len(raw))
}
if len(prefix) != 8 {
t.Errorf("expected 8-char prefix, got %d", len(prefix))
}
// prefix should match the first 8 hex chars after arm_pat_
hexPart := raw[len("arm_pat_"):]
if prefix != hexPart[:8] {
t.Errorf("prefix %q does not match hex start %q", prefix, hexPart[:8])
}
// Hash should be consistent
rehash := auth.HashToken(raw)
if hash != rehash {
t.Errorf("hash mismatch: generate=%q, rehash=%q", hash, rehash)
}
}
func TestGenerateToken_Unique(t *testing.T) {
raw1, _, _ := generateToken()
raw2, _, _ := generateToken()
if raw1 == raw2 {
t.Error("two generated tokens should not be identical")
}
}
// ── Handler Tests ───────────────────────────
func TestCreateToken_MissingName(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &APITokenHandler{stores: testStoresForTokens()}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "user-1")
c.Request = httptest.NewRequest("POST", "/auth/tokens",
strings.NewReader(`{"permissions":[]}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateToken(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestCreateToken_InvalidExpiry(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &APITokenHandler{stores: testStoresForTokens()}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "user-1")
c.Request = httptest.NewRequest("POST", "/auth/tokens",
strings.NewReader(`{"name":"test","permissions":[],"expires_at":"2020-01-01T00:00:00Z"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateToken(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if !strings.Contains(resp["error"], "future") {
t.Errorf("expected future error, got %q", resp["error"])
}
}
func TestCreateToken_PermissionSubsetValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
// User only has extension.use — requesting admin access should fail
stores := testStoresForTokens()
h := &APITokenHandler{stores: stores}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "user-1")
c.Request = httptest.NewRequest("POST", "/auth/tokens",
strings.NewReader(`{"name":"test","permissions":["surface.admin.access"]}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateToken(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestCreateToken_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
stores := testStoresForTokens()
h := &APITokenHandler{stores: stores}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "user-1")
c.Request = httptest.NewRequest("POST", "/auth/tokens",
strings.NewReader(`{"name":"my-token","permissions":["extension.use"]}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateToken(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp models.APITokenCreateResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !strings.HasPrefix(resp.Token, "arm_pat_") {
t.Errorf("expected arm_pat_ prefix in token")
}
if resp.Name != "my-token" {
t.Errorf("expected name 'my-token', got %q", resp.Name)
}
if len(resp.Prefix) != 8 {
t.Errorf("expected 8-char prefix, got %q", resp.Prefix)
}
}
func TestAdminCreateToken_MissingUserID(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &APITokenHandler{stores: testStoresForTokens()}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "admin-1")
c.Request = httptest.NewRequest("POST", "/admin/tokens",
strings.NewReader(`{"name":"test","permissions":[]}`))
c.Request.Header.Set("Content-Type", "application/json")
h.AdminCreateToken(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
// ── Test Stores ─────────────────────────────
// testStoresForTokens creates a minimal Stores with an in-memory token store
// and a mock group store that gives user-1 the extension.use permission.
func testStoresForTokens() store.Stores {
return store.Stores{
APITokens: &mockAPITokenStore{tokens: make(map[string]*models.APIToken)},
Groups: &mockGroupStoreForTokens{},
Users: &mockUserStoreForTokens{},
}
}
// ── Mock API Token Store ────────────────────
type mockAPITokenStore struct {
tokens map[string]*models.APIToken
}
func (m *mockAPITokenStore) Create(_ context.Context, token *models.APIToken) error {
if token.ID == "" {
token.ID = "tok-" + token.Prefix
}
token.CreatedAt = time.Now()
m.tokens[token.ID] = token
return nil
}
func (m *mockAPITokenStore) GetByHash(_ context.Context, hash string) (*models.APIToken, error) {
for _, t := range m.tokens {
if t.TokenHash == hash {
return t, nil
}
}
return nil, nil
}
func (m *mockAPITokenStore) ListForUser(_ context.Context, userID string) ([]models.APIToken, error) {
var result []models.APIToken
for _, t := range m.tokens {
if t.UserID == userID {
result = append(result, *t)
}
}
return result, nil
}
func (m *mockAPITokenStore) Revoke(_ context.Context, id, userID string) (int64, error) {
if t, ok := m.tokens[id]; ok && t.UserID == userID {
delete(m.tokens, id)
return 1, nil
}
return 0, nil
}
func (m *mockAPITokenStore) RevokeByID(_ context.Context, id string) (int64, error) {
if _, ok := m.tokens[id]; ok {
delete(m.tokens, id)
return 1, nil
}
return 0, nil
}
func (m *mockAPITokenStore) CleanExpired(_ context.Context) (int64, error) { return 0, nil }
func (m *mockAPITokenStore) UpdateLastUsed(_ context.Context, _ string) error { return nil }
// ── Mock Group Store ────────────────────────
type mockGroupStoreForTokens struct {
store.GroupStore // embed interface — only override what we need
}
func (m *mockGroupStoreForTokens) ListForUser(_ context.Context, userID string) ([]models.Group, error) {
if userID == "user-1" {
return []models.Group{
{BaseModel: models.BaseModel{ID: auth.EveryoneGroupID}, Permissions: []string{"extension.use"}},
}, nil
}
return []models.Group{}, nil
}
func (m *mockGroupStoreForTokens) GetUserGroupIDs(_ context.Context, _ string) ([]string, error) {
return []string{auth.EveryoneGroupID}, nil
}
// ── Mock User Store ─────────────────────────
type mockUserStoreForTokens struct {
store.UserStore // embed interface
}
func (m *mockUserStoreForTokens) GetByID(_ context.Context, id string) (*models.User, error) {
return &models.User{BaseModel: models.BaseModel{ID: id}, Username: "test", IsActive: true}, nil
}