The access review Add Source dialog listed each connector with no path to its setup documentation. Connectors that have a published docs page on probo.com now surface a "Documentation" link on the card, opening the page in a new tab; connectors without a page show nothing extra. The link is data-driven from the connector registry: a new DocumentationURL on the provider Registration, populated for the 12 documented providers via a single accessReviewDocsURL helper, is surfaced as a nullable documentationUrl on ConnectorProviderInfo and rendered by the console only when present. This keeps the registry the single source of truth and adds no client-side provider map. The links resolve once the probo.com access-review docs pages are deployed; until then they 404, so deploy the docs alongside this change. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
469 lines
13 KiB
Go
469 lines
13 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package console_test
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.probo.inc/probo/e2e/internal/testutil"
|
|
)
|
|
|
|
func TestAccessReviewDrivers(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
const query = `
|
|
query {
|
|
accessReviewDrivers {
|
|
provider
|
|
displayName
|
|
documentationUrl
|
|
oauthConfigured
|
|
apiKeySupported
|
|
clientCredentialsSupported
|
|
extraSettings {
|
|
key
|
|
label
|
|
required
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
AccessReviewDrivers []struct {
|
|
Provider string `json:"provider"`
|
|
DisplayName string `json:"displayName"`
|
|
DocumentationURL *string `json:"documentationUrl"`
|
|
OauthConfigured bool `json:"oauthConfigured"`
|
|
APIKeySupported bool `json:"apiKeySupported"`
|
|
ClientCredentialsSupported bool `json:"clientCredentialsSupported"`
|
|
ExtraSettings []struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Required bool `json:"required"`
|
|
} `json:"extraSettings"`
|
|
} `json:"accessReviewDrivers"`
|
|
}
|
|
|
|
err := owner.Execute(query, nil, &result)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, result.AccessReviewDrivers)
|
|
|
|
providerNames := make(map[string]bool)
|
|
docURLByProvider := make(map[string]*string)
|
|
|
|
for _, info := range result.AccessReviewDrivers {
|
|
assert.NotEmpty(t, info.Provider)
|
|
assert.NotEmpty(t, info.DisplayName)
|
|
assert.NotNil(t, info.ExtraSettings)
|
|
providerNames[info.Provider] = true
|
|
docURLByProvider[info.Provider] = info.DocumentationURL
|
|
}
|
|
|
|
assert.True(t, providerNames["BREX"], "expected BREX provider to be present")
|
|
assert.True(t, providerNames["HUBSPOT"], "expected HUBSPOT provider to be present")
|
|
|
|
// A documented provider exposes its probo.com docs URL; an undocumented one
|
|
// exposes null. See pkg/connector/provider/docs.go.
|
|
require.Contains(t, docURLByProvider, "ANTHROPIC")
|
|
|
|
if url := docURLByProvider["ANTHROPIC"]; assert.NotNil(t, url) {
|
|
assert.Equal(t, "https://www.probo.com/docs/product/access-review/anthropic", *url)
|
|
}
|
|
|
|
assert.Nil(t, docURLByProvider["BREX"], "BREX has no doc page, documentationUrl must be null")
|
|
|
|
t.Run("viewer can list access review drivers", func(t *testing.T) {
|
|
t.Parallel()
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
var viewerResult struct {
|
|
AccessReviewDrivers []struct {
|
|
Provider string `json:"provider"`
|
|
DisplayName string `json:"displayName"`
|
|
} `json:"accessReviewDrivers"`
|
|
}
|
|
|
|
err := viewer.Execute(query, nil, &viewerResult)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, viewerResult.AccessReviewDrivers)
|
|
})
|
|
}
|
|
|
|
func TestCreateAPIKeyConnector(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
const query = `
|
|
mutation($input: CreateAPIKeyConnectorInput!) {
|
|
createAPIKeyConnector(input: $input) {
|
|
connector {
|
|
id
|
|
provider
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAPIKeyConnector struct {
|
|
Connector struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
} `json:"connector"`
|
|
} `json:"createAPIKeyConnector"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"provider": "BREX",
|
|
"apiKey": "test-key-123",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
connector := result.CreateAPIKeyConnector.Connector
|
|
assert.NotEmpty(t, connector.ID)
|
|
assert.Equal(t, "BREX", connector.Provider)
|
|
}
|
|
|
|
func TestCreateAPIKeyConnectorWithSettings(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
const query = `
|
|
mutation($input: CreateAPIKeyConnectorInput!) {
|
|
createAPIKeyConnector(input: $input) {
|
|
connector {
|
|
id
|
|
provider
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAPIKeyConnector struct {
|
|
Connector struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
} `json:"connector"`
|
|
} `json:"createAPIKeyConnector"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"provider": "TALLY",
|
|
"apiKey": "test-key",
|
|
"tallyOrganizationId": "org-123",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
connector := result.CreateAPIKeyConnector.Connector
|
|
assert.NotEmpty(t, connector.ID)
|
|
assert.Equal(t, "TALLY", connector.Provider)
|
|
}
|
|
|
|
// TestCreateAPIKeyConnectorSentryMissingSlug asserts that creating a
|
|
// Sentry API-key connector without sentryOrganizationSlug returns a
|
|
// validation error, not a 500. This is the e2e gate on the
|
|
// MarshalSettings validation path introduced by the connector-provider
|
|
// consolidation.
|
|
func TestCreateAPIKeyConnectorSentryMissingSlug(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
const query = `
|
|
mutation($input: CreateAPIKeyConnectorInput!) {
|
|
createAPIKeyConnector(input: $input) {
|
|
connector { id }
|
|
}
|
|
}
|
|
`
|
|
|
|
_, err := owner.Do(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"provider": "SENTRY",
|
|
"apiKey": "test-key",
|
|
},
|
|
})
|
|
testutil.RequireErrorCode(t, err, "INVALID", "missing sentryOrganizationSlug must return INVALID not INTERNAL")
|
|
}
|
|
|
|
// TestCreateAPIKeyConnectorSentryRoundTrip asserts that supplying
|
|
// sentryOrganizationSlug succeeds and that the connector is created
|
|
// with the slug persisted in RawSettings.
|
|
func TestCreateAPIKeyConnectorSentryRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
const query = `
|
|
mutation($input: CreateAPIKeyConnectorInput!) {
|
|
createAPIKeyConnector(input: $input) {
|
|
connector { id provider }
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateAPIKeyConnector struct {
|
|
Connector struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
} `json:"connector"`
|
|
} `json:"createAPIKeyConnector"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"provider": "SENTRY",
|
|
"apiKey": "test-key",
|
|
"sentryOrganizationSlug": "my-org",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, result.CreateAPIKeyConnector.Connector.ID)
|
|
assert.Equal(t, "SENTRY", result.CreateAPIKeyConnector.Connector.Provider)
|
|
}
|
|
|
|
func TestCreateClientCredentialsConnector(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
const query = `
|
|
mutation($input: CreateClientCredentialsConnectorInput!) {
|
|
createClientCredentialsConnector(input: $input) {
|
|
connector {
|
|
id
|
|
provider
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var result struct {
|
|
CreateClientCredentialsConnector struct {
|
|
Connector struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
} `json:"connector"`
|
|
} `json:"createClientCredentialsConnector"`
|
|
}
|
|
|
|
err := owner.Execute(query, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"provider": "ONE_PASSWORD",
|
|
"clientId": "test-client",
|
|
"clientSecret": "test-secret",
|
|
"tokenUrl": "https://api.1password.com/v1beta1/users/oauth2/token",
|
|
"onePasswordAccountId": "ACC123",
|
|
"onePasswordRegion": "US",
|
|
},
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
connector := result.CreateClientCredentialsConnector.Connector
|
|
assert.NotEmpty(t, connector.ID)
|
|
assert.Equal(t, "ONE_PASSWORD", connector.Provider)
|
|
}
|
|
|
|
func TestDeleteConnector(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
// First, create a connector to delete.
|
|
const createQuery = `
|
|
mutation($input: CreateAPIKeyConnectorInput!) {
|
|
createAPIKeyConnector(input: $input) {
|
|
connector {
|
|
id
|
|
provider
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
var createResult struct {
|
|
CreateAPIKeyConnector struct {
|
|
Connector struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
} `json:"connector"`
|
|
} `json:"createAPIKeyConnector"`
|
|
}
|
|
|
|
err := owner.Execute(createQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": orgID,
|
|
"provider": "BREX",
|
|
"apiKey": "key-to-delete",
|
|
},
|
|
}, &createResult)
|
|
require.NoError(t, err)
|
|
|
|
connectorID := createResult.CreateAPIKeyConnector.Connector.ID
|
|
require.NotEmpty(t, connectorID)
|
|
|
|
// Now delete the connector.
|
|
const deleteQuery = `
|
|
mutation($input: DeleteConnectorInput!) {
|
|
deleteConnector(input: $input) {
|
|
deletedConnectorId
|
|
}
|
|
}
|
|
`
|
|
|
|
var deleteResult struct {
|
|
DeleteConnector struct {
|
|
DeletedConnectorID string `json:"deletedConnectorId"`
|
|
} `json:"deleteConnector"`
|
|
}
|
|
|
|
err = owner.Execute(deleteQuery, map[string]any{
|
|
"input": map[string]any{
|
|
"connectorId": connectorID,
|
|
},
|
|
}, &deleteResult)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, connectorID, deleteResult.DeleteConnector.DeletedConnectorID)
|
|
}
|
|
|
|
func TestCreateAPIKeyConnector_RBAC(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
t.Run("viewer cannot create connector", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := viewer.Do(`
|
|
mutation($input: CreateAPIKeyConnectorInput!) {
|
|
createAPIKeyConnector(input: $input) {
|
|
connector { id }
|
|
}
|
|
}
|
|
`, map[string]any{
|
|
"input": map[string]any{
|
|
"organizationId": viewer.GetOrganizationID().String(),
|
|
"provider": "BREX",
|
|
"apiKey": "test-key",
|
|
},
|
|
})
|
|
testutil.RequireForbiddenError(t, err, "viewer should not be able to create connector")
|
|
})
|
|
}
|
|
|
|
// TestCrispVerificationCode exercises the crispVerificationCode query end to end
|
|
// through the live schema and authorization stack. The code is a deterministic
|
|
// HMAC bound to (organization, website), so the query needs only the managed
|
|
// token secret (always set) and organization authorization — no Crisp
|
|
// credentials — and asserts the code's shape, determinism, org-binding, and the
|
|
// INVALID / FORBIDDEN error paths.
|
|
func TestCrispVerificationCode(t *testing.T) {
|
|
t.Parallel()
|
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
orgID := owner.GetOrganizationID().String()
|
|
|
|
const query = `
|
|
query($organizationId: ID!, $websiteId: String!) {
|
|
crispVerificationCode(organizationId: $organizationId, websiteId: $websiteId)
|
|
}
|
|
`
|
|
|
|
getCode := func(t *testing.T, client *testutil.Client, org, website string) string {
|
|
t.Helper()
|
|
|
|
var result struct {
|
|
CrispVerificationCode string `json:"crispVerificationCode"`
|
|
}
|
|
|
|
err := client.Execute(query, map[string]any{
|
|
"organizationId": org,
|
|
"websiteId": website,
|
|
}, &result)
|
|
require.NoError(t, err)
|
|
|
|
return result.CrispVerificationCode
|
|
}
|
|
|
|
const website = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
|
|
|
|
t.Run("owner gets a 12-char base32 code, deterministic per input", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
code := getCode(t, owner, orgID, website)
|
|
assert.Regexp(t, "^[A-Z2-7]{12}$", code)
|
|
|
|
// The same inputs re-derive the same code; nothing is stored.
|
|
assert.Equal(t, code, getCode(t, owner, orgID, website))
|
|
|
|
// A different website under the same organization yields a different code.
|
|
assert.NotEqual(t, code, getCode(t, owner, orgID, "99999999-0000-0000-0000-000000000000"))
|
|
})
|
|
|
|
t.Run("code is organization-bound", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
otherOwner := testutil.NewClient(t, testutil.RoleOwner)
|
|
|
|
mine := getCode(t, owner, orgID, website)
|
|
theirs := getCode(t, otherOwner, otherOwner.GetOrganizationID().String(), website)
|
|
assert.NotEqual(t, mine, theirs, "same website under different organizations must not share a code")
|
|
})
|
|
|
|
t.Run("blank websiteId is rejected as INVALID", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := owner.Do(query, map[string]any{
|
|
"organizationId": orgID,
|
|
"websiteId": " ",
|
|
})
|
|
testutil.RequireErrorCode(t, err, "INVALID", "blank websiteId must return INVALID not INTERNAL")
|
|
})
|
|
|
|
t.Run("viewer cannot read the verification code", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
|
|
|
_, err := viewer.Do(query, map[string]any{
|
|
"organizationId": viewer.GetOrganizationID().String(),
|
|
"websiteId": website,
|
|
})
|
|
testutil.RequireForbiddenError(t, err, "viewer should not be able to read the crisp verification code")
|
|
})
|
|
}
|