Files
probo/pkg/server/api/console/v1/crisp_test.go
Sacha Al Himdani 4c57d201a4 Make license declarations consistently MIT
The source headers, LICENSE files, and license metadata had drifted
apart. Align the entire project to MIT:

- Convert every source-file header to the MIT text across all comment
  styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including
  SPDX-License-Identifier tags
- Set the root and cookie-banner LICENSE files to the MIT text with a
  "MIT License" title line
- Switch the package.json license fields, Docker image label, and
  cookie-banner README to MIT
- Update docs and the genmodels header generator accordingly
- Normalize copyright lines to a single format
  (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the
  hello@getprobo.com and hello@probo.inc emails to hello@probo.com and
  the comma-separated years to a hyphenated range

Genuine third-party references are intentionally left untouched: the
Lucide icon attributions (Lucide is ISC) and the trivy dependency
license allowlist.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-13 16:21:14 +02:00

354 lines
12 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_v1
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/connector/provider"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
)
func TestComputeCrispVerificationCode(t *testing.T) {
t.Parallel()
const (
secret = "test-token-secret"
org = "gid://organization/1"
website = "e8592878-c0d0-4632-b2f7-7d882f288d43"
)
t.Run("is deterministic for the same inputs", func(t *testing.T) {
t.Parallel()
a := computeCrispVerificationCode(secret, org, website)
b := computeCrispVerificationCode(secret, org, website)
assert.Equal(t, a, b)
})
t.Run("is bound to the organization", func(t *testing.T) {
t.Parallel()
a := computeCrispVerificationCode(secret, "gid://organization/1", website)
b := computeCrispVerificationCode(secret, "gid://organization/2", website)
assert.NotEqual(t, a, b)
})
t.Run("is bound to the website", func(t *testing.T) {
t.Parallel()
a := computeCrispVerificationCode(secret, org, "website-a")
b := computeCrispVerificationCode(secret, org, "website-b")
assert.NotEqual(t, a, b)
})
t.Run("depends on the secret", func(t *testing.T) {
t.Parallel()
a := computeCrispVerificationCode("secret-a", org, website)
b := computeCrispVerificationCode("secret-b", org, website)
assert.NotEqual(t, a, b)
})
t.Run("is 12 human-typeable base32 characters", func(t *testing.T) {
t.Parallel()
code := computeCrispVerificationCode(secret, org, website)
assert.Len(t, code, crispVerificationCodeLength)
assert.Regexp(t, regexp.MustCompile(`^[A-Z2-7]{12}$`), code)
})
// Delimiting org and website prevents a boundary collision where a longer
// org and shorter website (or vice versa) concatenate to the same bytes.
t.Run("delimiter prevents org/website boundary collisions", func(t *testing.T) {
t.Parallel()
a := computeCrispVerificationCode(secret, "ab", "c")
b := computeCrispVerificationCode(secret, "a", "bc")
assert.NotEqual(t, a, b)
})
}
func TestVerifyCrispVerificationCode(t *testing.T) {
t.Parallel()
const (
secret = "test-token-secret"
org = "gid://organization/1"
website = "e8592878-c0d0-4632-b2f7-7d882f288d43"
)
valid := computeCrispVerificationCode(secret, org, website)
require.NotEmpty(t, valid)
t.Run("accepts the exact code", func(t *testing.T) {
t.Parallel()
assert.True(t, verifyCrispVerificationCode(secret, org, website, valid))
})
t.Run("tolerates surrounding whitespace and lowercase", func(t *testing.T) {
t.Parallel()
assert.True(t, verifyCrispVerificationCode(secret, org, website, " "+valid+" "))
assert.True(t, verifyCrispVerificationCode(secret, org, website, strings.ToLower(valid)))
})
t.Run("rejects a mismatched or empty code", func(t *testing.T) {
t.Parallel()
assert.False(t, verifyCrispVerificationCode(secret, org, website, ""))
assert.False(t, verifyCrispVerificationCode(secret, org, website, "AAAAAAAAAAAA"))
})
t.Run("rejects a code minted for another organization", func(t *testing.T) {
t.Parallel()
other := computeCrispVerificationCode(secret, "gid://organization/2", website)
assert.False(t, verifyCrispVerificationCode(secret, org, website, other))
})
t.Run("rejects a code minted for another website", func(t *testing.T) {
t.Parallel()
other := computeCrispVerificationCode(secret, org, "another-website")
assert.False(t, verifyCrispVerificationCode(secret, org, website, other))
})
}
// The stored Crisp Website ID must be the SAME trimmed value that
// verifyCrispOwnership and the CrispVerificationCode query derive the code
// against, or a padded ID would verify then break the driver's request URL.
func TestApiKeyConnectorSettings_CrispTrimsWebsiteID(t *testing.T) {
t.Parallel()
website := " e8592878-c0d0-4632-b2f7-7d882f288d43 "
raw, err := apiKeyConnectorSettings(types.CreateAPIKeyConnectorInput{
Provider: coredata.ConnectorProviderCrisp,
CrispWebsiteID: &website,
})
require.NoError(t, err)
var settings coredata.CrispConnectorSettings
require.NoError(t, json.Unmarshal(raw, &settings))
assert.Equal(t, "e8592878-c0d0-4632-b2f7-7d882f288d43", settings.WebsiteID)
}
func TestApiKeyConnectorSettings_CrispRejectsWhitespaceOnly(t *testing.T) {
t.Parallel()
website := " "
_, err := apiKeyConnectorSettings(types.CreateAPIKeyConnectorInput{
Provider: coredata.ConnectorProviderCrisp,
CrispWebsiteID: &website,
})
require.Error(t, err)
}
// resolveAPIKeyConnectorCredential is the sole gate keeping normal API-key
// providers requiring a customer key while ManagedAPIKey providers (Model B,
// e.g. Crisp) persist none. A regression either drops the required-key check for
// every provider or persists the managed key on the row.
func TestResolveAPIKeyConnectorCredential(t *testing.T) {
t.Parallel()
// NewBuiltinRegistry registers Crisp as a ManagedAPIKey provider; the
// managed key must then be set for it to count as configured.
configuredReg := provider.NewBuiltinRegistry()
configuredReg.SetManagedAPIKey(coredata.ConnectorProviderCrisp, "identifier:secret")
configured := &Resolver{providerRegistry: configuredReg}
unconfigured := &Resolver{providerRegistry: provider.NewBuiltinRegistry()}
clientKey := "customer-key"
empty := ""
t.Run("managed and configured persists no key", func(t *testing.T) {
t.Parallel()
key, err := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderCrisp, nil)
require.NoError(t, err)
assert.Equal(t, "", key)
})
t.Run("managed and configured ignores a client-supplied key", func(t *testing.T) {
t.Parallel()
key, err := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderCrisp, &clientKey)
require.NoError(t, err)
assert.Equal(t, "", key)
})
t.Run("managed but unconfigured is rejected", func(t *testing.T) {
t.Parallel()
_, err := unconfigured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderCrisp, nil)
require.Error(t, err)
})
t.Run("non-managed requires a key", func(t *testing.T) {
t.Parallel()
_, errNil := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderTally, nil)
require.EqualError(t, errNil, "apiKey is required")
_, errEmpty := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderTally, &empty)
require.EqualError(t, errEmpty, "apiKey is required")
})
t.Run("non-managed returns the client key", func(t *testing.T) {
t.Parallel()
key, err := configured.resolveAPIKeyConnectorCredential(coredata.ConnectorProviderTally, &clientKey)
require.NoError(t, err)
assert.Equal(t, "customer-key", key)
})
}
// verifyCrispOwnershipWith is the #1b create-time ownership gate. This pins its
// branch wiring: the security polarity (only a matching code passes) and the
// Invalid-versus-Internal error mapping. The settings fetch is faked so no live
// Crisp API is reached.
func TestVerifyCrispOwnershipWith(t *testing.T) {
t.Parallel()
const (
secret = "test-token-secret"
pluginID = "plugin-id"
)
orgID := gid.New(gid.NewTenantID(), coredata.OrganizationEntityType)
websiteID := "e8592878-c0d0-4632-b2f7-7d882f288d43"
validCode := computeCrispVerificationCode(secret, orgID.String(), websiteID)
// newResolver builds a Resolver whose registry has Crisp configured as a
// managed provider; the plugin ID is set only when withPluginID is true.
newResolver := func(withPluginID bool) *Resolver {
reg := provider.NewBuiltinRegistry()
reg.SetManagedAPIKey(coredata.ConnectorProviderCrisp, "identifier:secret")
if withPluginID {
reg.SetManagedResourceID(coredata.ConnectorProviderCrisp, pluginID)
}
return &Resolver{
providerRegistry: reg,
tokenSecret: secret,
logger: log.NewLogger(log.WithOutput(io.Discard)),
}
}
newInput := func(website *string) types.CreateAPIKeyConnectorInput {
return types.CreateAPIKeyConnectorInput{
OrganizationID: orgID,
Provider: coredata.ConnectorProviderCrisp,
CrispWebsiteID: website,
}
}
fetchCode := func(code string) crispSettingsFetcher {
return func(context.Context, *http.Client, string, string) (*drivers.CrispSubscriptionSettings, error) {
return &drivers.CrispSubscriptionSettings{ProboVerificationCode: code}, nil
}
}
assertCode := func(t *testing.T, err error, code string) {
t.Helper()
require.Error(t, err)
gqlErr, ok := err.(*gqlerror.Error)
require.True(t, ok, "expected *gqlerror.Error, got %T", err)
assert.Equal(t, code, gqlErr.Extensions["code"])
}
t.Run("missing website id is invalid", func(t *testing.T) {
t.Parallel()
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(nil), fetchCode(validCode))
assertCode(t, err, "INVALID")
})
t.Run("whitespace website id is invalid", func(t *testing.T) {
t.Parallel()
blank := " "
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&blank), fetchCode(validCode))
assertCode(t, err, "INVALID")
})
t.Run("missing plugin id is internal", func(t *testing.T) {
t.Parallel()
err := newResolver(false).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetchCode(validCode))
assertCode(t, err, "INTERNAL")
})
t.Run("plugin not subscribed is invalid", func(t *testing.T) {
t.Parallel()
fetch := func(context.Context, *http.Client, string, string) (*drivers.CrispSubscriptionSettings, error) {
return nil, drivers.ErrCrispPluginNotSubscribed
}
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetch)
assertCode(t, err, "INVALID")
})
t.Run("other fetch error is internal and leaks nothing", func(t *testing.T) {
t.Parallel()
fetch := func(context.Context, *http.Client, string, string) (*drivers.CrispSubscriptionSettings, error) {
return nil, errors.New("boom: plugin token 12345 rejected")
}
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetch)
assertCode(t, err, "INTERNAL")
assert.NotContains(t, err.Error(), "boom")
assert.NotContains(t, err.Error(), "12345")
})
t.Run("mismatched code is invalid", func(t *testing.T) {
t.Parallel()
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetchCode("WRONGCODE000"))
assertCode(t, err, "INVALID")
})
t.Run("matching code passes", func(t *testing.T) {
t.Parallel()
err := newResolver(true).verifyCrispOwnershipWith(context.Background(), newInput(&websiteID), fetchCode(validCode))
require.NoError(t, err)
})
}