Files
probo/e2e/console/connector_test.go
Aurélien Sibiril fb68e98941 Split connector extra settings per credential path
Registration.ExtraSettings was a single flat list, but the API-key and
client-credentials connect dialogs need different fields whenever a
provider offers both paths, because a different create resolver and a
different driver sits behind each. Replace it with
APIKeyExtraSettings and ClientCredentialsExtraSettings, and split the
GraphQL surface to match so a client cannot render one path's settings
on the other.

This fixes two connectors that could not be connected at all.

1Password declared accountId and region only, which are the
client-credentials shape. The API-key dialog therefore rendered those
two fields, mapAPIKeyExtraSettingToField returned nil for both so
buildExtraFields discarded them, and the SCIM-bridge driver failed on an
empty SCIMBridgeURL. The console already mapped scimBridgeUrl, but no
registration declared that key, so the branch was dead. It now declares
scimBridgeUrl on the API-key path and accountId + region on client
credentials.

Langfuse declared baseUrl as required, but mapAPIKeyExtraSettingToField
had no LANGFUSE case, so buildExtraFields dropped the value the customer
typed and the mutation failed with "langfuseBaseUrl is required". Every
other extra-settings provider had a case. The GraphQL input field, the
settings struct, the probe builder and the driver were all already
correct; only the console mapping was missing.

buildExtraFields now takes the settings list explicitly instead of
reading it off the provider, so each dialog passes its own path's list
and cannot silently iterate the other one.

Register rejects a settings list for a path the provider does not offer,
and an empty or duplicate setting key within one list. A key repeated
across the two lists is allowed: that is how a dual-path provider
declares a setting both dialogs need.

The new resolver tests walk the whole chain the console walks, from the
key a Registration declares through the mutation input field to the
persisted settings struct, so a key renamed on one side and not the
other fails in CI instead of at connect time.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-26 15:56:10 +02:00

499 lines
15 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
apiKeyExtraSettings {
key
label
required
}
clientCredentialsExtraSettings {
key
label
required
}
}
}
`
type settingInfo struct {
Key string `json:"key"`
Label string `json:"label"`
Required bool `json:"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"`
APIKeyExtraSettings []settingInfo `json:"apiKeyExtraSettings"`
ClientCredentialsExtraSettings []settingInfo `json:"clientCredentialsExtraSettings"`
} `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)
apiKeySettingKeys := make(map[string][]string)
clientCredentialsSettingKeys := make(map[string][]string)
for _, info := range result.AccessReviewDrivers {
assert.NotEmpty(t, info.Provider)
assert.NotEmpty(t, info.DisplayName)
assert.NotNil(t, info.APIKeyExtraSettings)
assert.NotNil(t, info.ClientCredentialsExtraSettings)
providerNames[info.Provider] = true
docURLByProvider[info.Provider] = info.DocumentationURL
for _, s := range info.APIKeyExtraSettings {
apiKeySettingKeys[info.Provider] = append(apiKeySettingKeys[info.Provider], s.Key)
}
for _, s := range info.ClientCredentialsExtraSettings {
clientCredentialsSettingKeys[info.Provider] = append(clientCredentialsSettingKeys[info.Provider], s.Key)
}
}
assert.True(t, providerNames["BREX"], "expected BREX provider to be present")
assert.True(t, providerNames["HUBSPOT"], "expected HUBSPOT provider to be present")
// 1Password is the only provider offering both connect paths, and each path
// needs different settings: the SCIM-bridge driver behind the API key, the
// Users API driver behind client credentials. A client rendering one path's
// settings on the other would collect fields the create resolver rejects.
assert.Equal(t, []string{"scimBridgeUrl"}, apiKeySettingKeys["ONE_PASSWORD"])
assert.Equal(t, []string{"accountId", "region"}, clientCredentialsSettingKeys["ONE_PASSWORD"])
// A single-path provider declares its settings on that path only.
assert.Equal(t, []string{"baseUrl"}, apiKeySettingKeys["LANGFUSE"])
assert.Empty(t, clientCredentialsSettingKeys["LANGFUSE"])
// 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")
})
}