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 }