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
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:
267
server/handlers/api_tokens_test.go
Normal file
267
server/handlers/api_tokens_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user