Files
grafana/pkg/tests/apis/provisioning/repository_test.go
T
Roberto Jiménez Sánchez 9760eef62f Provisioning: fix multi-tenant and single-tenant authorization (#115435)
* feat(auth): add ExtraAudience option to RoundTripper

Add ExtraAudience option to RoundTripper to allow operators to include
additional audiences (e.g., provisioning group) when connecting to the
multitenant aggregator. This ensures tokens include both the target API
server's audience and the provisioning group audience, which is required
to pass the enforceManagerProperties check.

- Add ExtraAudience RoundTripperOption
- Improve documentation and comments
- Add comprehensive test coverage

* fix(operators): add ExtraAudience for dashboards/folders API servers

Operators connecting to dashboards and folders API servers need to include
the provisioning group audience in addition to the target API server's
audience to pass the enforceManagerProperties check.

* provisioning: fix settings/stats authorization for AccessPolicy identities

The settings and stats endpoints were returning 403 for users accessing via
ST->MT because the AccessPolicy identity was routed to the access checker,
which doesn't know about these resources.

This fix handles 'settings' and 'stats' resources before the access checker
path, routing them to the role-based authorization that allows:
- settings: Viewer role (read-only, needed by frontend)
- stats: Admin role (can leak information)

* fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic

- Removed legacy storage flag from useResourceStats hook in BootstrapStep.
- Updated BootstrapStepResourceCounting to simplify rendering logic and removed target prop.
- Adjusted tests to reflect changes in resource counting and rendering behavior.

* Revert "fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic"

This reverts commit 148802cbb5.

* provisioning: allow any authenticated user for settings/stats endpoints

These are read-only endpoints needed by the frontend:
- settings: returns available repository types and configuration for the wizard
- stats: returns resource counts

Authentication is verified before reaching authorization, so any user who
reaches these endpoints is already authenticated. Requiring specific org
roles failed for AccessPolicy tokens which don't carry traditional roles.

* provisioning: remove redundant admin role check from listFolderFiles

The admin role check in listFolderFiles was redundant (route-level auth already
handles access) and broken for AccessPolicy identities which don't have org roles.

File access is controlled by the AccessClient as documented in the route-level
authorization comment.

* provisioning: add isAdminOrAccessPolicy helper for auth checks

Consolidates authorization logic for provisioning endpoints:
- Adds isAdminOrAccessPolicy() helper that allows admin users OR AccessPolicy identities
- AccessPolicy identities (ST->MT flow) are trusted internal callers without org roles
- Regular users must have admin role (matching frontend navtree restriction)

Used in: authorizeSettings, authorizeStats, authorizeJobs, listFolderFiles

* provisioning: consolidate auth helpers into allowForAdminsOrAccessPolicy

Simplifies authorization by:
- Adding isAccessPolicy() helper for AccessPolicy identity check
- Adding allowForAdminsOrAccessPolicy() that returns Decision directly
- Consolidating stats/settings/jobs into single switch case
- Using consistent pattern in files.go

* provisioning: require admin for files subresource at route level

Aligns route-level authorization with handler-level check in listFolderFiles.
Both now require admin role OR AccessPolicy identity for consistency.

* provisioning: restructure authorization with role-based helpers

Reorganizes authorization code for clarity:

Role-based helpers (all support AccessPolicy for ST->MT flow):
- allowForAdminsOrAccessPolicy: admin role required
- allowForEditorsOrAccessPolicy: editor role required
- allowForViewersOrAccessPolicy: viewer role required

Repository subresources by role:
- Admin: repository CRUD, test, files
- Editor: jobs, resources, sync, history
- Viewer: refs, status (GET only)

Connection subresources by role:
- Admin: connection CRUD
- Viewer: status (GET only)

* provisioning: move refs to admin-only

refs subresource now requires admin role (or AccessPolicy).
Updated documentation comments to reflect current permissions.

* provisioning: add fine-grained permissions for connections

Adds connection permissions following the same pattern as repositories:
- provisioning.connections:create
- provisioning.connections:read
- provisioning.connections:write
- provisioning.connections:delete

Roles:
- fixed:provisioning.connections:reader (granted to Admin)
- fixed:provisioning.connections:writer (granted to Admin)

* provisioning: remove non-existent sync subresource from auth

The sync subresource doesn't exist - syncing is done via the jobs endpoint.
Removed dead code from authorization switch case.

* provisioning: use access checker for fine-grained permissions

Refactors authorization to use b.access.Check() with verb-based checks:

Repository subresources:
- CRUD: uses actual verb (get/create/update/delete)
- test: uses 'update' (write permission)
- files/refs/resources/history/status: uses 'get' (read permission)
- jobs: uses actual verb for jobs resource

Connection subresources:
- CRUD: uses actual verb
- status: uses 'get' (read permission)

The access checker maps verbs to actions defined in accesscontrol.go.
Falls back to admin role for backwards compatibility.

Also removes redundant admin check from listFolderFiles since
authorization is now properly handled at route level.

* provisioning: use verb constants instead of string literals

Uses apiutils.VerbGet, apiutils.VerbUpdate instead of "get", "update".

* provisioning: use access checker for jobs and historicjobs resources

Jobs resource: uses actual verb (create/read/write/delete)
HistoricJobs resource: read-only (historicjobs:read)

* provisioning: allow viewers to access settings endpoint

Settings is read-only and needed by multiple UI pages (not just admin pages).
Stats remains admin-only.

* provisioning: consolidate role-based resource authorization

Extract isRoleBasedResource() and authorizeRoleBasedResource() helpers
to avoid duplicating settings/stats resource checks in multiple places.

* provisioning: use resource name constants instead of hardcoded strings

Replace 'repositories', 'connections', 'jobs', 'historicjobs' with
their corresponding ResourceInfo.GetName() constants.

* provisioning: delegate file authorization to connector

Route level: allow any authenticated user for files subresource
Connector: check repositories:read only for directory listing
Individual file CRUD: handled by DualReadWriter based on actual resource

* provisioning: enhance authorization for files and jobs resources

Updated file authorization to fall back to admin role for listing files. Introduced checkAccessForJobs function to manage job permissions, allowing editors to create and manage jobs while maintaining admin-only access for historic jobs. Improved error messaging for permission denials.

* provisioning: refactor authorization with fine-grained permissions

Authorization changes:
- Use access checker with role-based fallback for backwards compatibility
- Repositories/Connections: admin role fallback
- Jobs: editor role fallback (editors can manage jobs)
- HistoricJobs: admin role fallback (read-only)
- Settings: viewer role (needed by multiple UI pages)
- Stats: admin role

Files subresource:
- Route level allows any authenticated user
- Directory listing checks repositories:read in connector
- Individual file CRUD delegated to DualReadWriter

Refactored checkAccessWithFallback to accept fallback role parameter.

* provisioning: refactor access checker integration for improved authorization

Updated the authorization logic to utilize the new access checker across various resources, including files and jobs. This change simplifies the permission checks by removing redundant identity retrieval and enhances error handling. The access checker now supports role-based fallbacks for admin and editor roles, ensuring backward compatibility while streamlining the authorization process for repository and connection subresources.

* provisioning: remove legacy access checker tests and refactor access checker implementation

Deleted the access_checker_test.go file to streamline the codebase and focus on the updated access checker implementation. Refactored the access checker to enhance clarity and maintainability, ensuring it supports role-based fallback behavior. Updated the access checker integration in the API builder to utilize the new fallback role configuration, improving authorization logic across resources.

* refactor: split AccessChecker into TokenAccessChecker and SessionAccessChecker

- Renamed NewMultiTenantAccessChecker -> NewTokenAccessChecker (uses AuthInfoFrom)
- Renamed NewSingleTenantAccessChecker -> NewSessionAccessChecker (uses GetRequester)
- Split into separate files with their own tests
- Added mockery-generated mock for AccessChecker interface
- Names now reflect identity source rather than deployment mode

* fix: correct error message case and use accessWithAdmin for filesConnector

- Fixed error message to use lowercase 'admin role is required'
- Fixed filesConnector to use accessWithAdmin for proper role fallback
- Formatted code

* refactor: reduce cyclomatic complexity in filesConnector.Connect

Split the Connect handler into smaller focused functions:
- handleRequest: main request processing
- createDualReadWriter: setup dependencies
- parseRequestOptions: extract request options
- handleDirectoryListing: GET directory requests
- handleMethodRequest: route to method handlers
- handleGet/handlePost/handlePut/handleDelete: method-specific logic
- handleMove: move operation logic

* security: remove blind TypeAccessPolicy bypass from access checkers

Removed the code that bypassed authorization for TypeAccessPolicy identities.
All identities now go through proper permission verification via the inner
access checker, which will validate permissions from ServiceIdentityClaims.

This addresses the security concern where TypeAccessPolicy was being trusted
blindly without verifying whether the identity came from the wire or in-process.

* feat: allow editors to access repository refs subresource

Change refs authorization from admin to editor fallback so editors can
view repository branches when pushing changes to dashboards/folders.

- Split refs from other read-only subresources (resources, history, status)
- refs now uses accessWithEditor instead of accessWithAdmin
- Updated documentation comment to reflect authorization levels
- Added integration test TestIntegrationProvisioning_RefsPermissions
  verifying editor access and viewer denial

* tests: add authorization tests for missing provisioning API endpoints

Add comprehensive authorization tests for:
- Repository subresources (test, resources, history, status)
- Connection status subresource
- HistoricJobs resource
- Settings and Stats resources

All authorization paths are now covered by integration tests.

* test: fix RefsPermissions test to use GitHub repository

Use github-readonly.json.tmpl template instead of local folder,
since refs endpoint requires a versioned repository that supports
git operations.

* chore: format test files

* fix: make settings/stats authorization work in MT mode

Update authorizeRoleBasedResource to check authlib.AuthInfoFrom(ctx)
for AccessPolicy identity type in addition to identity.GetRequester(ctx).
This ensures AccessPolicy identities are recognized in MT mode where
identity.GetRequester may not set the identity type correctly.

* fix: remove unused authorization helper functions

Remove allowForAdminsOrAccessPolicy and allowForViewersOrAccessPolicy
as they are no longer used after refactoring to use authorizeRoleBasedResource.

* Fix AccessPolicy identity detection in ST authorizer

- Add check for AccessPolicy identities via GetAuthID() in authorizeRoleBasedResource
- Extended JWT may set identity type to TypeUser but AuthID is 'access-policy:...'
- Forward user ID token in X-Grafana-Id header in RoundTripper for aggregator forwarding

* Revert "Fix AccessPolicy identity detection in ST authorizer"

This reverts commit 0f4885e503.

* Add fine-grained permissions for settings and stats endpoints

- Add provisioning.settings:read action (granted to Viewer role)
- Add provisioning.stats:read action (granted to Admin role)
- Add accessWithViewer to APIBuilder for Viewer role fallback
- Use access checker for settings/stats authorization
- Remove role-based authorization functions (isRoleBasedResource, authorizeRoleBasedResource)

This makes settings and stats consistent with other provisioning resources
and works properly in both ST and MT modes via the access checker.

* Remove AUTHORIZATION_COVERAGE.md

* Add provisioning resources to RBAC mapper

- Add connections, settings, stats to provisioning.grafana.app mappings
- Required for authz service to translate K8s verbs to legacy actions
- Fixes 403 errors for settings/stats in MT mode

* refactor: merge access checkers with original fallthrough behavior

Merge tokenAccessChecker and sessionAccessChecker into a unified
access checker that implements the original fallthrough behavior:

1. First try to get identity from access token (authlib.AuthInfoFrom)
2. If token exists AND (is TypeAccessPolicy OR useExclusivelyAccessCheckerForAuthz),
   use the access checker with token identity
3. If no token or conditions not met, fall back to session identity
   (identity.GetRequester) with optional role-based fallback

This fixes the issue where settings/stats/connections endpoints were
failing in MT mode because the tokenAccessChecker was returning an error
when there was no auth info in context, instead of falling through to
session-based authorization.

The unified checker now properly handles:
- MT mode: tries token first, falls back to session if no token
- ST mode: only uses token for AccessPolicy identities, otherwise session
- Role fallback: applies when configured and access checker denies

* Revert "refactor: merge access checkers with original fallthrough behavior"

This reverts commit 96451f948b.

* Grant settings view role to all

* fix: use actual request verb for settings/stats authorization

Use a.GetVerb() instead of hardcoded VerbGet for settings and stats
authorization. When listing resources (hitting collection endpoint),
the verb is 'list' not 'get', and this mismatch could cause issues
with the RBAC service.

* debug: add logging to access checkers for authorization debugging

Add klog debug logs (V4 level) to token and session access checkers
to help diagnose why settings/stats authorization is failing while
connections works.

* debug: improve access checker logging with grafana-app-sdk logger

- Use grafana-app-sdk logging.FromContext instead of klog
- Add error wrapping with resource.group format for better context
- Log more details including folder, group, and allowed status
- Log error.Error() for better error message visibility

* chore: use generic log messages in access checkers

* Revert "Grant settings view role to all"

This reverts commit 3f5758cf36.

* fix: use request verb for historicjobs authorization

The original role-based check allowed any verb for admins. To preserve
this behavior with the access checker, we should pass the actual verb
from the request instead of hardcoding VerbGet.

---------

Co-authored-by: Charandas Batra <charandas.batra@grafana.com>
2025-12-19 15:11:35 +01:00

1022 lines
38 KiB
Go

package provisioning
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"
provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
inputFiles := []string{
"testdata/github-readonly.json.tmpl",
"testdata/local-readonly.json.tmpl",
}
for _, inputFilePath := range inputFiles {
t.Run(inputFilePath, func(t *testing.T) {
input := helper.RenderObject(t, inputFilePath, nil)
name := mustNestedString(input.Object, "metadata", "name")
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
// Move encrypted token mutation
token, found, err := unstructured.NestedString(output.Object, "secure", "token", "name")
require.NoError(t, err, "secure token name is not a string")
if found {
require.True(t, strings.HasPrefix("inline-", token)) // name created automatically
err = unstructured.SetNestedField(input.Object, token, "secure", "token", "name")
require.NoError(t, err, "unable to copy secure token")
}
// Marshal as real objects to ",omitempty" values are tested properly
expectedRepo := unstructuredToRepository(t, input)
returnedRepo := unstructuredToRepository(t, output)
require.Equal(t, expectedRepo.Spec, returnedRepo.Spec)
// A viewer should not be able to see the same thing
var statusCode int
rsp := helper.ViewerREST.Get().
Namespace("default").
Resource("repositories").
Name(name).
Do(context.Background())
require.Error(t, rsp.Error())
rsp.StatusCode(&statusCode)
require.Equal(t, http.StatusForbidden, statusCode)
// Viewer can see file listing
rsp = helper.AdminREST.Get().
Namespace("default").
Resource("repositories").
Name(name).
Suffix("files/").
Do(context.Background())
require.NoError(t, rsp.Error())
// Verify that we can list refs
rsp = helper.AdminREST.Get().
Namespace("default").
Resource("repositories").
Name(name).
Suffix("refs").
Do(context.Background())
if expectedRepo.Spec.Type == provisioning.LocalRepositoryType {
require.ErrorContains(t, rsp.Error(), "does not support versioned operations")
} else {
require.NoError(t, rsp.Error())
refs := &provisioning.RefList{}
err = rsp.Into(refs)
require.NoError(t, err)
require.True(t, len(refs.Items) >= 1, "should have at least one ref")
var foundBranch bool
for _, ref := range refs.Items {
// FIXME: this assertion should be improved for all git types and take things from config
if ref.Name == "integration-test" {
require.Equal(t, "0f3370c212b04b9704e00f6926ef339bf91c7a1b", ref.Hash)
require.Equal(t, "https://github.com/grafana/grafana-git-sync-demo/tree/integration-test", ref.RefURL)
foundBranch = true
}
}
require.True(t, foundBranch, "branch should be found")
}
})
}
// Viewer can see settings listing
t.Run("viewer has access to list", func(t *testing.T) {
require.EventuallyWithT(t, func(collect *assert.CollectT) {
settings := &provisioning.RepositoryViewList{}
rsp := helper.ViewerREST.Get().
Namespace("default").
Suffix("settings").
Do(context.Background())
if !assert.NoError(collect, rsp.Error()) {
return
}
err := rsp.Into(settings)
if !assert.NoError(collect, err) {
return
}
if !assert.Len(collect, settings.Items, len(inputFiles)) {
return
}
for _, i := range settings.Items {
switch i.Type {
case provisioning.LocalRepositoryType:
assert.Equal(collect, i.Path, helper.ProvisioningPath)
case provisioning.GitHubRepositoryType:
assert.Equal(collect, i.URL, "https://github.com/grafana/grafana-git-sync-demo")
assert.Equal(collect, i.Path, "grafana/")
default:
assert.NotEmpty(collect, i.Path)
assert.NotEmpty(collect, i.URL)
}
}
assert.ElementsMatch(collect, []provisioning.RepositoryType{
provisioning.LocalRepositoryType,
provisioning.GitHubRepositoryType,
}, settings.AvailableRepositoryTypes)
}, time.Second*10, time.Millisecond*100, "Expected settings to match")
})
t.Run("Repositories are reported in stats", func(t *testing.T) {
require.EventuallyWithT(t, func(collect *assert.CollectT) {
report := apis.DoRequest(helper.K8sTestHelper, apis.RequestParams{
Method: http.MethodGet,
Path: "/api/admin/usage-report-preview",
User: helper.Org1.Admin,
}, &usagestats.Report{})
stats := map[string]any{}
for k, v := range report.Result.Metrics {
if strings.HasPrefix(k, "stats.repository.") {
stats[k] = v
}
}
assert.Equal(collect, map[string]any{
"stats.repository.github.count": 1.0,
"stats.repository.local.count": 1.0,
}, stats)
}, time.Second*10, time.Millisecond*100, "Expected stats to match")
})
}
func TestIntegrationProvisioning_RepositoryValidation(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
for _, testCase := range []struct {
name string
repo *unstructured.Unstructured
expectedErr string
}{
{
name: "should succeed with valid local repository",
repo: func() *unstructured.Unstructured {
return helper.RenderObject(t, "testdata/local-readonly.json.tmpl", map[string]any{
"Name": "valid-repo",
"SyncEnabled": true,
})
}(),
},
{
name: "should error if mutually exclusive finalizers are set",
repo: func() *unstructured.Unstructured {
localTmp := helper.RenderObject(t, "testdata/local-readonly.json.tmpl", map[string]any{
"Name": "repo-with-invalid-finalizers",
"SyncEnabled": true,
})
// Setting finalizers to trigger a failure
localTmp.SetFinalizers([]string{
repository.CleanFinalizer,
repository.ReleaseOrphanResourcesFinalizer,
repository.RemoveOrphanResourcesFinalizer,
})
return localTmp
}(),
expectedErr: "cannot have both remove and release orphan resources finalizers",
},
} {
t.Run(testCase.name, func(t *testing.T) {
_, err := helper.Repositories.Resource.Create(ctx, testCase.repo, metav1.CreateOptions{})
if testCase.expectedErr == "" {
assert.NoError(t, err)
} else {
assert.Error(t, err)
assert.ErrorContains(t, err, testCase.expectedErr)
}
})
}
// Test Git repository path validation - ensure child paths are rejected
t.Run("Git repository path validation", func(t *testing.T) {
baseURL := "https://github.com/grafana/test-repo-path-validation"
pathTests := []struct {
name string
path string
expectError error
}{
{
name: "first repo with path 'demo/nested' should succeed",
path: "demo/nested",
expectError: nil,
},
{
name: "second repo with child path 'demo/nested/again' should fail",
path: "demo/nested/again",
expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
},
{
name: "third repo with parent path 'demo' should fail",
path: "demo",
expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
},
{
name: "fourth repo with nested child path 'demo/nested/nested-second' should fail",
path: "demo/nested/again/two",
expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
},
{
name: "fifth repo with duplicate path 'demo/nested' should fail",
path: "demo/nested",
expectError: provisioningAPIServer.ErrRepositoryDuplicatePath,
},
}
for i, test := range pathTests {
t.Run(test.name, func(t *testing.T) {
repoName := fmt.Sprintf("git-path-test-%d", i+1)
gitRepo := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{
"Name": repoName,
"URL": baseURL,
"Path": test.path,
"SyncEnabled": false, // Disable sync to avoid external dependencies
"SyncTarget": "folder",
})
_, err := helper.Repositories.Resource.Create(ctx, gitRepo, metav1.CreateOptions{FieldValidation: "Strict"})
if test.expectError != nil {
require.Error(t, err, "Expected error for repository with path: %s", test.path)
require.ErrorContains(t, err, test.expectError.Error(), "Error should contain expected message for path: %s", test.path)
var statusError *apierrors.StatusError
if errors.As(err, &statusError) {
require.Equal(t, metav1.StatusReasonInvalid, statusError.ErrStatus.Reason, "Should be a validation error")
require.Equal(t, http.StatusUnprocessableEntity, int(statusError.ErrStatus.Code), "Should return 422 status code")
}
} else {
require.NoError(t, err, "Expected success for repository with path: %s", test.path)
}
})
}
})
}
func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
t.Skip("Reenable this test once we enforce schema validation for provisioning")
helper := runGrafana(t)
ctx := context.Background()
const repo = "invalid-schema-tmp"
// Set up the repository and the file to import.
helper.CopyToProvisioningPath(t, "testdata/invalid-dashboard-schema.json", "invalid-dashboard-schema.json")
localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
"Name": repo,
"SyncEnabled": true,
})
_, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
require.NoError(t, err)
// Make sure the repo can read and validate the file
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "invalid-dashboard-schema.json")
status := helper.RequireApiErrorStatus(err, metav1.StatusReasonBadRequest, http.StatusBadRequest)
require.Equal(t, status.Message, "Dry run failed: Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]")
const invalidSchemaUid = "invalid-schema-uid"
_, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{})
require.Error(t, err, "invalid dashboard shouldn't exist")
require.True(t, apierrors.IsNotFound(err))
helper.DebugState(t, repo, "BEFORE PULL JOB WITH INVALID SCHEMA")
spec := provisioning.JobSpec{
Action: provisioning.JobActionPull,
Pull: &provisioning.SyncJobOptions{},
}
result := helper.TriggerJobAndWaitForComplete(t, repo, spec)
job := &provisioning.Job{}
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, job)
require.NoError(t, err, "should convert to Job object")
assert.Equal(t, provisioning.JobStateError, job.Status.State)
assert.Equal(t, job.Status.Message, "completed with errors")
assert.Equal(t, job.Status.Errors[0], "Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]")
_, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{})
require.Error(t, err, "invalid dashboard shouldn't have been created")
require.True(t, apierrors.IsNotFound(err))
err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}, "files", "invalid-dashboard-schema.json")
require.NoError(t, err, "should delete the resource file")
}
func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// FIXME: instead of using an existing GitHub repository, we should create a new one for the tests and a branch
// This was the previous structure
// ghmock.WithRequestMatchHandler(ghmock.GetReposGitTreesByOwnerByRepoByTreeSha,
// ghHandleTree(t, map[string][]*gh.TreeEntry{
// "deadbeef": {
// treeEntryDir("grafana", "subtree"),
// },
// "subtree": {
// treeEntry("dashboard.json", helper.LoadFile("testdata/all-panels.json")),
// treeEntryDir("subdir", "subtree2"),
// treeEntry("subdir/dashboard2.yaml", helper.LoadFile("testdata/text-options.json")),
// },
// })),
// FIXME: uncomment these to implement webhook integration tests.
// helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient(
// ghmock.WithRequestMatchHandler(ghmock.GetReposHooksByOwnerByRepo, ghAlwaysWrite(t, []*gh.Hook{})),
// ghmock.WithRequestMatchHandler(ghmock.PostReposHooksByOwnerByRepo, ghAlwaysWrite(t, &gh.Hook{ID: gh.Ptr(int64(123))})),
// )
const repo = "github-create-test"
testRepo := TestRepo{
Name: repo,
Template: "testdata/github-readonly.json.tmpl",
Target: "folder",
ExpectedDashboards: 3,
ExpectedFolders: 3, // Folder sync creates an additional folder for the repository itself
}
helper.CreateRepo(t, testRepo)
// By now, we should have synced, meaning we have data to read in the local Grafana instance!
found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "can list values")
names := []string{}
for _, v := range found.Items {
names = append(names, v.GetName())
}
require.Len(t, names, 3, "should have three dashboards")
assert.Contains(t, names, "adg5vbj", "should contain dashboard.json's contents")
assert.Contains(t, names, "admfz74", "should contain dashboard2.yaml's contents")
assert.Contains(t, names, "adn5mxb", "should contain dashboard2.yaml's contents")
err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{})
require.NoError(t, err, "should delete values")
require.EventuallyWithT(t, func(collect *assert.CollectT) {
found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
assert.NoError(t, err, "can list values")
assert.Equal(collect, 0, len(found.Items), "expected dashboards to be deleted")
}, time.Second*20, time.Millisecond*10, "Expected dashboards to be deleted")
// Wait for repository to be fully deleted before subtests run
require.EventuallyWithT(t, func(collect *assert.CollectT) {
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted")
}, time.Second*10, time.Millisecond*50, "repository should be deleted before subtests")
t.Run("github url cleanup", func(t *testing.T) {
tests := []struct {
name string
input string
output string
}{
{
name: "simple-url",
input: "https://github.com/dprokop/grafana-git-sync-test",
output: "https://github.com/dprokop/grafana-git-sync-test",
},
{
name: "trim-dot-git",
input: "https://github.com/dprokop/grafana-git-sync-test.git",
output: "https://github.com/dprokop/grafana-git-sync-test",
},
{
name: "trim-slash",
input: "https://github.com/dprokop/grafana-git-sync-test/",
output: "https://github.com/dprokop/grafana-git-sync-test",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create repository directly without health checks since we're only testing URL cleanup
input := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{
"Name": test.name,
"URL": test.input,
"SyncTarget": "folder",
"SyncEnabled": false, // Disable sync since we're just testing URL cleanup,
"Path": fmt.Sprintf("grafana-%s/", test.name),
})
_, err := helper.Repositories.Resource.Create(ctx, input, metav1.CreateOptions{})
require.NoError(t, err, "failed to create resource")
obj, err := helper.Repositories.Resource.Get(ctx, test.name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
url, _, err := unstructured.NestedString(obj.Object, "spec", "github", "url")
require.NoError(t, err, "failed to read URL")
require.Equal(t, test.output, url)
})
}
})
}
func TestIntegrationProvisioning_RepositoryLimits(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
originalName := "original-repo"
// Create instance sync repository first
originalRepo := TestRepo{
Name: originalName,
Target: "instance",
Copies: map[string]string{}, // No files needed for this test
ExpectedDashboards: 0,
ExpectedFolders: 0,
}
helper.CreateRepo(t, originalRepo)
t.Run("folder sync is rejected when instance sync exists", func(t *testing.T) {
folderRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
"Name": "folder-blocked-by-instance",
"SyncEnabled": true,
"SyncTarget": "folder",
})
_, err := helper.Repositories.Resource.Create(ctx, folderRepo, metav1.CreateOptions{FieldValidation: "Strict"})
require.Error(t, err, "folder sync repository should be rejected when instance sync exists")
// Verify the error message mentions the existing instance repository
statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
require.Contains(t, statusError.Message, "Cannot create folder repository when instance repository exists: "+originalName)
})
t.Run("change between folder and instance sync for the same repository if no previous sync happened", func(t *testing.T) {
repo, err := helper.Repositories.Resource.Get(ctx, originalName, metav1.GetOptions{})
require.NoError(t, err, "failed to get repository")
err = unstructured.SetNestedField(repo.Object, "folder", "spec", "sync", "target")
require.NoError(t, err, "failed to set syncTarget to folder")
_, err = helper.Repositories.Resource.Update(ctx, repo, metav1.UpdateOptions{FieldValidation: "Strict"})
require.NoError(t, err, "failed to update repository to folder sync")
// Verify that the repository is now a folder sync
// We verify with the listing APIs because it may take some time for the update to propagate
require.Eventually(t, func() bool {
repos, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{})
if err != nil {
return false
}
for _, repo := range repos.Items {
if repo.GetName() == originalName {
syncTarget, found, err := unstructured.NestedString(repo.Object, "spec", "sync", "target")
if err != nil || !found {
return false
}
return syncTarget == "folder"
}
}
return false
}, time.Second*10, time.Millisecond*100, "failed to verify that sync target is folder")
})
t.Run("instance sync rejected when any other repository exists", func(t *testing.T) {
instanceRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
"Name": "instance-repo-blocked",
"SyncEnabled": true,
"SyncTarget": "instance",
})
_, err := helper.Repositories.Resource.Create(ctx, instanceRepo, metav1.CreateOptions{FieldValidation: "Strict"})
require.Error(t, err, "instance sync repository should be rejected when any other repository exists")
statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
require.Contains(t, statusError.Message, "Instance repository can only be created when no other repositories exist. Found: "+originalName)
})
t.Run("repository limit validation of 10 for folder syncs repositories", func(t *testing.T) {
for i := 2; i <= 10; i++ {
repoName := fmt.Sprintf("limit-test-repo-%d", i)
limitTestRepo := TestRepo{
Name: repoName,
Target: "folder",
Copies: map[string]string{}, // No files needed for this test
ExpectedDashboards: 0,
ExpectedFolders: i, // Each repository creates a folder, so total = i
}
helper.CreateRepo(t, limitTestRepo)
}
// Try to create the 11th repository - should fail due to limit
eleventhRepoName := "limit-test-repo-11"
eleventhRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
"Name": eleventhRepoName,
"SyncEnabled": true,
"SyncTarget": "folder",
})
_, err := helper.Repositories.Resource.Create(ctx, eleventhRepo, metav1.CreateOptions{FieldValidation: "Strict"})
require.Error(t, err, "11th repository should be rejected due to limit")
statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
require.Contains(t, statusError.Message, "Maximum number of 10 repositories reached")
})
}
func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
const allPanels = "n1jR8vnnz"
const repo = "local-local-examples"
const targetPath = "all-panels.json"
// Set up the repository.
helper.CreateRepo(t, TestRepo{
Name: repo,
Target: "folder",
ExpectedDashboards: 0,
ExpectedFolders: 1, // folder sync creates a folder for the repo
SkipResourceAssertions: false,
})
// Write a file -- this will create it *both* in the local file system, and in grafana
t.Run("write all panels", func(t *testing.T) {
code := 0
// Check that we can not (yet) UPDATE the target path
result := helper.AdminREST.Put().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", targetPath).
Body(helper.LoadFile("testdata/all-panels.json")).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&code)
require.Equal(t, http.StatusNotFound, code)
require.True(t, apierrors.IsNotFound(result.Error()))
// Now try again with POST (as an editor)
result = helper.EditorREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", targetPath).
Body(helper.LoadFile("testdata/all-panels.json")).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&code)
require.NoError(t, result.Error(), "expecting to be able to create file")
wrapper := &provisioning.ResourceWrapper{}
raw, err := result.Raw()
require.NoError(t, err)
err = json.Unmarshal(raw, wrapper)
require.NoError(t, err)
require.Equal(t, 200, code, "expected 200 response")
require.Equal(t, provisioning.ClassicDashboard, wrapper.Resource.Type.Classic)
name, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "name")
require.Equal(t, allPanels, name, "name from classic UID")
name, _, _ = unstructured.NestedString(wrapper.Resource.Upsert.Object, "metadata", "name")
require.Equal(t, allPanels, name, "save the name from the request")
// Get the file from the grafana database
obj, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
require.NoError(t, err, "the value should be saved in grafana")
val, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerKind)
require.Equal(t, string(utils.ManagerKindRepo), val, "should have repo annotations")
val, _, _ = unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerIdentity)
require.Equal(t, repo, val, "should have repo annotations")
// Read the file we wrote
wrapObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", targetPath)
require.NoError(t, err, "read value")
wrap := &unstructured.Unstructured{}
wrap.Object, _, err = unstructured.NestedMap(wrapObj.Object, "resource", "dryRun")
require.NoError(t, err)
meta, err := utils.MetaAccessor(wrap)
require.NoError(t, err)
require.Equal(t, allPanels, meta.GetName(), "read the name out of the saved file")
// Check that an admin can update
meta.SetAnnotation("test", "from-provisioning")
body, err := json.Marshal(wrap.Object)
require.NoError(t, err)
result = helper.AdminREST.Put().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", targetPath).
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&code)
require.Equal(t, 200, code)
require.NoError(t, result.Error(), "update as admin value")
raw, err = result.Raw()
require.NoError(t, err)
err = json.Unmarshal(raw, wrapper)
require.NoError(t, err)
anno, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "annotations", "test")
require.Equal(t, "from-provisioning", anno, "should set the annotation")
// But a viewer can not
result = helper.ViewerREST.Put().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", targetPath).
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&code)
require.Equal(t, 403, code)
require.True(t, apierrors.IsForbidden(result.Error()), code)
})
t.Run("fail using invalid paths", func(t *testing.T) {
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "test", "..", "..", "all-panels.json"). // UNSAFE PATH
Body(helper.LoadFile("testdata/all-panels.json")).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.Error(t, result.Error(), "invalid path should return error")
// Read a file with a bad path
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "../../all-panels.json")
require.Error(t, err, "invalid path should error")
})
t.Run("require name or generateName", func(t *testing.T) {
code := 0
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "example.json").
Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
spec:
title: Test dashboard
`)).Do(ctx).StatusCode(&code)
require.Error(t, result.Error(), "missing name")
result = helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("files", "example.json").
Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
generateName: prefix-
spec:
title: Test dashboard
`)).Do(ctx).StatusCode(&code)
require.NoError(t, result.Error(), "should create name")
require.Equal(t, 200, code, "expect OK result")
raw, err := result.Raw()
require.NoError(t, err)
obj := &unstructured.Unstructured{}
err = json.Unmarshal(raw, obj)
require.NoError(t, err)
name, _, _ := unstructured.NestedString(obj.Object, "resource", "upsert", "metadata", "name")
require.True(t, strings.HasPrefix(name, "prefix-"), "should generate name")
})
}
func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// The dashboard shouldn't exist yet
const allPanels = "n1jR8vnnz"
_, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
require.Error(t, err, "no all-panels dashboard should exist")
require.True(t, apierrors.IsNotFound(err))
const repo = "local-tmp"
// Set up the repository and the file to import.
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{"testdata/all-panels.json": "all-panels.json"},
ExpectedDashboards: 1,
ExpectedFolders: 1, // folder sync creates a folder
}
// We create the repository
helper.CreateRepo(t, testRepo)
// Now, we import it, such that it may exist
// The sync may not be necessary as the sync may have happened automatically at this point
helper.SyncAndWait(t, repo, nil)
// Make sure the repo can read and validate the file
obj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json")
require.NoError(t, err, "valid path should be fine")
resource, _, err := unstructured.NestedMap(obj.Object, "resource")
require.NoError(t, err, "missing resource")
require.NoError(t, err, "invalid action")
require.NotNil(t, resource["file"], "the raw file")
require.NotNil(t, resource["dryRun"], "dryRun result")
action, _, err := unstructured.NestedString(resource, "action")
require.NoError(t, err, "invalid action")
// FIXME: there is no point in in returning action for a read / get request.
require.Equal(t, "update", action)
_, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "can list values")
obj, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
require.NoError(t, err, "all-panels dashboard should exist")
require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity])
// Try writing the value directly
err = unstructured.SetNestedField(obj.Object, []any{"aaa", "bbb"}, "spec", "tags")
require.NoError(t, err, "set tags")
obj, err = helper.DashboardsV1.Resource.Update(ctx, obj, metav1.UpdateOptions{})
require.NoError(t, err)
v, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyUpdatedBy)
require.Equal(t, "access-policy:provisioning", v)
// Should be able to directly delete the managed resource
err = helper.DashboardsV1.Resource.Delete(ctx, allPanels, metav1.DeleteOptions{})
require.NoError(t, err, "user can delete")
_, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
require.Error(t, err, "should delete the internal resource")
require.True(t, apierrors.IsNotFound(err))
}
func TestIntegrationProvisioning_DeleteRepositoryAndReleaseResources(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
const repo = "gh-repo"
testRepo := TestRepo{
Name: repo,
Template: "testdata/github-readonly.json.tmpl",
Target: "folder",
ExpectedDashboards: 3,
ExpectedFolders: 3,
}
helper.CreateRepo(t, testRepo)
// Checking resources are there and are managed
foundFolders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "can list folders")
for _, v := range foundFolders.Items {
assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
}
foundDashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "can list dashboards")
for _, v := range foundDashboards.Items {
assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
assert.Contains(t, v.GetAnnotations(), utils.AnnoKeySourcePath)
assert.Contains(t, v.GetAnnotations(), utils.AnnoKeySourceChecksum)
}
_, err = helper.Repositories.Resource.Patch(ctx, repo, types.JSONPatchType, []byte(`[
{
"op": "replace",
"path": "/metadata/finalizers",
"value": ["cleanup", "release-orphan-resources"]
}
]`), metav1.PatchOptions{})
require.NoError(t, err, "should successfully patch finalizers")
err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{})
require.NoError(t, err, "should delete repository")
require.EventuallyWithT(t, func(collect *assert.CollectT) {
_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted")
}, time.Second*10, time.Millisecond*50, "repository should be deleted")
require.EventuallyWithT(t, func(collect *assert.CollectT) {
foundDashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
assert.NoError(t, err, "can list values")
for _, v := range foundDashboards.Items {
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourcePath)
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourceChecksum)
}
}, time.Second*20, time.Millisecond*10, "Expected dashboards to be released")
require.EventuallyWithT(t, func(collect *assert.CollectT) {
foundFolders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
assert.NoError(t, err, "can list values")
for _, v := range foundFolders.Items {
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourcePath)
assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourceChecksum)
}
}, time.Second*20, time.Millisecond*10, "Expected folders to be released")
}
func TestIntegrationProvisioning_JobPermissions(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
const repo = "job-permissions-test"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{}, // No files needed for this test
ExpectedDashboards: 0,
ExpectedFolders: 1, // Repository creates a folder
}
helper.CreateRepo(t, testRepo)
jobSpec := provisioning.JobSpec{
Action: provisioning.JobActionPull,
Pull: &provisioning.SyncJobOptions{},
}
body := asJSON(jobSpec)
t.Run("editor can POST jobs", func(t *testing.T) {
var statusCode int
result := helper.EditorREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&statusCode)
require.NoError(t, result.Error(), "editor should be able to POST jobs")
require.Equal(t, http.StatusAccepted, statusCode, "should return 202 Accepted")
// Verify the job was created
obj, err := result.Get()
require.NoError(t, err, "should get job object")
unstruct, ok := obj.(*unstructured.Unstructured)
require.True(t, ok, "expecting unstructured object")
require.NotEmpty(t, unstruct.GetName(), "job should have a name")
})
t.Run("viewer cannot POST jobs", func(t *testing.T) {
var statusCode int
result := helper.ViewerREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&statusCode)
require.Error(t, result.Error(), "viewer should not be able to POST jobs")
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
})
t.Run("admin can POST jobs", func(t *testing.T) {
var statusCode int
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx).StatusCode(&statusCode)
// Job might already exist from previous test, which is acceptable
if apierrors.IsAlreadyExists(result.Error()) {
// Wait for the existing job to complete
helper.AwaitJobs(t, repo)
return
}
require.NoError(t, result.Error(), "admin should be able to POST jobs")
require.Equal(t, http.StatusAccepted, statusCode, "should return 202 Accepted")
})
}
func TestIntegrationProvisioning_RefsPermissions(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
const repo = "refs-permissions-test"
testRepo := TestRepo{
Name: repo,
Template: "testdata/github-readonly.json.tmpl",
Target: "folder",
ExpectedDashboards: 3,
ExpectedFolders: 3, // Repository creates folders
}
helper.CreateRepo(t, testRepo)
t.Run("editor can GET refs", func(t *testing.T) {
var statusCode int
result := helper.EditorREST.Get().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("refs").
Do(ctx).StatusCode(&statusCode)
require.NoError(t, result.Error(), "editor should be able to GET refs")
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
// Verify we can parse the refs and it contains at least main branch
refs := &provisioning.RefList{}
err := result.Into(refs)
require.NoError(t, err, "should parse refs response")
require.NotEmpty(t, refs.Items, "should have at least one ref")
})
t.Run("viewer cannot GET refs", func(t *testing.T) {
var statusCode int
result := helper.ViewerREST.Get().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("refs").
Do(ctx).StatusCode(&statusCode)
require.Error(t, result.Error(), "viewer should not be able to GET refs")
require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden")
require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden")
})
t.Run("admin can GET refs", func(t *testing.T) {
var statusCode int
result := helper.AdminREST.Get().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("refs").
Do(ctx).StatusCode(&statusCode)
require.NoError(t, result.Error(), "admin should be able to GET refs")
require.Equal(t, http.StatusOK, statusCode, "should return 200 OK")
})
}