Personal access tokens (PATs) for programmatic API access with SHA-256 hashing, permission scoping (git model), and Settings/Admin UI. Extension-declared user permissions with dynamic registry, gate_permission manifest field, permissions Starlark module, and grouped admin UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
268 lines
7.4 KiB
Go
268 lines
7.4 KiB
Go
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
|
|
}
|