mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Require installation auth for Patrol quickstart
This commit is contained in:
@@ -129,7 +129,10 @@ management, and fleet control surfaces.
|
||||
may be invoked by lifecycle-adjacent surfaces, but server-issued
|
||||
quickstart tokens, Patrol quickstart credit snapshots, and AI runtime auth
|
||||
remain `ai-runtime` plus `api-contracts` concerns rather than install-token
|
||||
or lifecycle credential state.
|
||||
or lifecycle credential state. Installation activation remains the only
|
||||
authority for Patrol quickstart bootstrap, so lifecycle flows must not
|
||||
reintroduce anonymous bootstrap identity or tenant-local commercial-owner
|
||||
surrogates when they traverse those shared handlers.
|
||||
2. Add or change update continuity and persisted-version handoff through `internal/agentupdate/`.
|
||||
3. Add or change runtime-side Unified Agent startup, first-report assembly, and enroll/runtime continuity through `internal/hostagent/`.
|
||||
Proxmox host-agent setup must treat local `proxmox-registered` markers as a cache, not authority: before skipping token setup or node repair, `internal/hostagent/proxmox_setup.go` must revalidate the current type and candidate hosts against Pulse through the canonical auto-register contract.
|
||||
|
||||
@@ -121,16 +121,19 @@ surface that lives behind the public license API rather than a tenant-local or
|
||||
mobile-local adapter.
|
||||
That same Patrol quickstart boundary is now server-authoritative end to end.
|
||||
`internal/ai/quickstart.go` must bootstrap before the first Patrol-only
|
||||
quickstart use, prefer installation identity from `activation.enc` when an
|
||||
activation token exists, and otherwise persist one stable community
|
||||
`client_installation_id` plus the returned token snapshot under
|
||||
`quickstart.enc` through the shared `internal/config/persistence.go` and
|
||||
`internal/config/quickstart_state.go` helpers. `internal/ai/providers/quickstart.go`
|
||||
must authenticate Patrol proxy calls with `Authorization: Bearer <quickstart_token>`,
|
||||
sync `credits_remaining` / `credits_total` back into that cache on every server
|
||||
response, and invalidate the cached token on auth rejection instead of reviving
|
||||
local counter truth. Explicit BYOK provider credentials still outrank Patrol
|
||||
quickstart whenever both are present.
|
||||
quickstart use, resolve installation identity from the installation-scoped
|
||||
`activation.enc` path even when Patrol runs under a tenant-local persistence
|
||||
directory, and refuse bootstrap entirely when no valid installation token is
|
||||
available. `quickstart.enc` may cache only the server-issued token snapshot
|
||||
and latest server-reported inventory through the shared
|
||||
`internal/config/persistence.go` and `internal/config/quickstart_state.go`
|
||||
helpers; it must not persist anonymous bootstrap identity or revive local
|
||||
counter truth. `internal/ai/providers/quickstart.go` must authenticate Patrol
|
||||
proxy calls with `Authorization: Bearer <quickstart_token>`, sync
|
||||
`credits_remaining` / `credits_total` back into that cache on every server
|
||||
response, and invalidate the cached token on auth rejection. Explicit BYOK
|
||||
provider credentials still outrank Patrol quickstart whenever both are
|
||||
present.
|
||||
Public-facing copy that reflects those runtime fields must therefore speak in
|
||||
Patrol quickstart runs and Patrol-only no-key activation, not in generic AI
|
||||
credits or a promise of full hosted chat.
|
||||
|
||||
@@ -238,6 +238,10 @@ Own canonical runtime payload shapes between backend and frontend.
|
||||
than from local grant counters, and shared handlers must not invent
|
||||
client-authored commercial identity or synthetic credits when the quickstart
|
||||
server is unavailable
|
||||
and the activation-gated availability rule, so missing installation
|
||||
activation/trial identity must surface as the canonical activation-required
|
||||
quickstart block reason for Patrol and AI settings enablement rather than
|
||||
silently attempting anonymous bootstrap
|
||||
and the public interpretation rule, so those fields describe Patrol-only
|
||||
quickstart inventory and active runtime source rather than a generic hosted
|
||||
AI quota or full-chat entitlement
|
||||
|
||||
@@ -245,11 +245,13 @@ That same license-server transport boundary now owns Patrol quickstart
|
||||
bootstrap. `pkg/licensing/license_server_client.go` and
|
||||
`pkg/licensing/quickstart_bootstrap.go` must treat
|
||||
`POST /v1/quickstart/bootstrap` as the canonical exchange for a server-issued
|
||||
quickstart token plus the authoritative quickstart credit snapshot. Activated
|
||||
installs authenticate that bootstrap with the installation token, community
|
||||
installs send a stable `client_installation_id`, and local runtime cache files
|
||||
may memoize the returned token and counts but may not treat those cached counts
|
||||
as commercial authority.
|
||||
quickstart token plus the authoritative quickstart credit snapshot. The Pulse
|
||||
runtime must authenticate that bootstrap with the installation token from the
|
||||
installation-scoped activation state, whether the install is activated
|
||||
self-hosted or hosted/trial-backed, and there is no anonymous
|
||||
`client_installation_id` fallback in the v6 runtime contract. Local runtime
|
||||
cache files may memoize the returned token and counts but may not treat those
|
||||
cached counts as commercial authority.
|
||||
That quickstart allowance is therefore activation support, not the main
|
||||
commercial pitch: self-hosted pricing and docs may promise Patrol-only
|
||||
quickstart runs with no API key for first use, but they must not market that
|
||||
|
||||
@@ -109,8 +109,8 @@ querying, and the operator-facing storage health presentation layer.
|
||||
`quickstart_credits_total`, `using_quickstart`, and cached bearer-token
|
||||
state exposed through shared AI handlers are transport facts owned by the
|
||||
AI/runtime plus API-contract lanes, and storage/recovery surfaces must not
|
||||
reintroduce local quickstart accounting, token lifecycle, or commercial
|
||||
identity rules.
|
||||
reintroduce local quickstart accounting, token lifecycle, anonymous
|
||||
bootstrap identity, or commercial identity rules.
|
||||
21. Keep provider-backed recovery onboarding on the adjacent platform-connections contract. When `internal/api/` grows or changes TrueNAS connection CRUD, masked-secret preservation, saved-connection retest routes, edit-form saved-test payload overlays, or similar provider setup flows, storage and recovery may consume the resulting recovery points but must not absorb that connection-management ownership into storage/recovery-local handlers or page flows.
|
||||
22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape.
|
||||
23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape.
|
||||
|
||||
@@ -75,6 +75,22 @@ describe('patrol api', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the activation-required quickstart blocked reason payload', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
runtime_state: 'blocked',
|
||||
blocked_reason:
|
||||
'Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key.',
|
||||
healthy: false,
|
||||
} as any);
|
||||
|
||||
await expect(getPatrolStatus()).resolves.toMatchObject({
|
||||
runtime_state: 'blocked',
|
||||
blocked_reason:
|
||||
'Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key.',
|
||||
healthy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the canonical patrol quickstart transport fields', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
runtime_state: 'active',
|
||||
|
||||
@@ -89,8 +89,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
patrolQuickstartCreditsExhaustedReason = "Quickstart credits exhausted. Connect your API key to continue using AI Patrol."
|
||||
patrolQuickstartUnavailableReason = "Quickstart credits require internet access. Connect your API key for offline AI Patrol."
|
||||
patrolQuickstartCreditsExhaustedReason = "Quickstart credits exhausted. Connect your API key to continue using AI Patrol."
|
||||
patrolQuickstartActivationRequiredReason = "Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key."
|
||||
patrolQuickstartUnavailableReason = "Quickstart credits require internet access. Connect your API key for offline AI Patrol."
|
||||
)
|
||||
|
||||
type PatrolStatus struct {
|
||||
|
||||
@@ -1597,6 +1597,7 @@ func (p *PatrolService) GetStatus() PatrolStatus {
|
||||
if quickstartReason != "" {
|
||||
status.BlockedReason = quickstartReason
|
||||
} else if strings.TrimSpace(status.BlockedReason) == patrolQuickstartCreditsExhaustedReason ||
|
||||
strings.TrimSpace(status.BlockedReason) == patrolQuickstartActivationRequiredReason ||
|
||||
strings.TrimSpace(status.BlockedReason) == patrolQuickstartUnavailableReason {
|
||||
status.BlockedReason = ""
|
||||
}
|
||||
|
||||
+122
-46
@@ -10,7 +10,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
@@ -49,7 +48,6 @@ type PersistentQuickstartCreditManager struct {
|
||||
client quickstartBootstrapClient
|
||||
now func() time.Time
|
||||
hostname func() (string, error)
|
||||
newID func() string
|
||||
state *config.QuickstartState
|
||||
}
|
||||
|
||||
@@ -88,7 +86,6 @@ func NewPersistentQuickstartCreditManagerWithClient(
|
||||
client: client,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
hostname: os.Hostname,
|
||||
newID: uuid.NewString,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,11 +104,15 @@ func (m *PersistentQuickstartCreditManager) EnsureBootstrap(ctx context.Context)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bearerToken, fingerprint, err := m.secureBootstrapIdentityLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.bootstrapNeededLocked(state, m.now()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
bearerToken, req, err := m.bootstrapRequestLocked(state)
|
||||
req, err := m.bootstrapRequestLocked(fingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -134,23 +135,66 @@ func (m *PersistentQuickstartCreditManager) EnsureBootstrap(ctx context.Context)
|
||||
}
|
||||
|
||||
func (m *PersistentQuickstartCreditManager) HasCredits() bool {
|
||||
state := m.stateSnapshot()
|
||||
return state != nil && state.QuickstartCreditsRemaining > 0
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
state, err := m.loadStateLocked()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state")
|
||||
return false
|
||||
}
|
||||
if state == nil || state.QuickstartCreditsRemaining <= 0 {
|
||||
return false
|
||||
}
|
||||
_, _, err = m.secureBootstrapIdentityLocked()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *PersistentQuickstartCreditManager) CreditsRemaining() int {
|
||||
state := m.stateSnapshot()
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
state, err := m.loadStateLocked()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state")
|
||||
return 0
|
||||
}
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
if _, _, err := m.secureBootstrapIdentityLocked(); err != nil {
|
||||
return 0
|
||||
}
|
||||
return state.QuickstartCreditsRemaining
|
||||
}
|
||||
|
||||
func (m *PersistentQuickstartCreditManager) CreditsTotal() int {
|
||||
state := m.stateSnapshot()
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
state, err := m.loadStateLocked()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("orgID", m.orgID).Msg("Quickstart: failed to read cached state")
|
||||
return 0
|
||||
}
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
if _, _, err := m.secureBootstrapIdentityLocked(); err != nil {
|
||||
return 0
|
||||
}
|
||||
return state.QuickstartCreditsTotal
|
||||
}
|
||||
|
||||
@@ -181,6 +225,9 @@ func (m *PersistentQuickstartCreditManager) GetProvider() providers.Provider {
|
||||
if state == nil || state.QuickstartCreditsRemaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, _, err := m.secureBootstrapIdentityLocked(); err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(state.QuickstartToken) == "" || state.TokenExpired(m.now()) {
|
||||
return nil
|
||||
}
|
||||
@@ -241,49 +288,17 @@ func (m *PersistentQuickstartCreditManager) bootstrapNeededLocked(state *config.
|
||||
return now.Unix()-*state.LastSyncedAt >= int64(quickstartBootstrapRefreshWindow/time.Second)
|
||||
}
|
||||
|
||||
func (m *PersistentQuickstartCreditManager) bootstrapRequestLocked(state *config.QuickstartState) (string, pkglicensing.QuickstartBootstrapRequest, error) {
|
||||
if state == nil {
|
||||
return "", pkglicensing.QuickstartBootstrapRequest{}, fmt.Errorf("quickstart: missing state")
|
||||
}
|
||||
|
||||
func (m *PersistentQuickstartCreditManager) bootstrapRequestLocked(fingerprint string) (pkglicensing.QuickstartBootstrapRequest, error) {
|
||||
instanceName := ""
|
||||
if hostname, err := m.hostname(); err == nil {
|
||||
instanceName = strings.TrimSpace(hostname)
|
||||
}
|
||||
|
||||
req := pkglicensing.QuickstartBootstrapRequest{
|
||||
InstanceName: instanceName,
|
||||
UseCase: quickstartBootstrapUseCase,
|
||||
}
|
||||
|
||||
if m.persistence == nil {
|
||||
return "", req, fmt.Errorf("quickstart: persistence unavailable")
|
||||
}
|
||||
|
||||
licensePersistence, err := pkglicensing.NewPersistence(m.persistence.GetConfigDir())
|
||||
if err != nil {
|
||||
return "", req, fmt.Errorf("quickstart: load license persistence: %w", err)
|
||||
}
|
||||
activationState, err := licensePersistence.LoadActivationState()
|
||||
if err != nil {
|
||||
return "", req, fmt.Errorf("quickstart: load activation state: %w", err)
|
||||
}
|
||||
|
||||
if activationState != nil && strings.TrimSpace(activationState.InstallationToken) != "" {
|
||||
req.InstanceFingerprint = strings.TrimSpace(activationState.InstanceFingerprint)
|
||||
return strings.TrimSpace(activationState.InstallationToken), req, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(state.ClientInstallationID) == "" {
|
||||
state.ClientInstallationID = m.newID()
|
||||
if err := m.persistence.SaveQuickstartState(*config.NormalizeQuickstartState(state)); err != nil {
|
||||
return "", req, fmt.Errorf("quickstart: persist client installation id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req.ClientInstallationID = state.ClientInstallationID
|
||||
req.InstanceFingerprint = state.ClientInstallationID
|
||||
return "", req, nil
|
||||
return pkglicensing.QuickstartBootstrapRequest{
|
||||
InstanceFingerprint: strings.TrimSpace(fingerprint),
|
||||
InstanceName: instanceName,
|
||||
UseCase: quickstartBootstrapUseCase,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *PersistentQuickstartCreditManager) applyBootstrapLocked(state *config.QuickstartState, resp *pkglicensing.QuickstartBootstrapResponse) {
|
||||
@@ -361,12 +376,40 @@ func (m *PersistentQuickstartCreditManager) invalidateToken() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PersistentQuickstartCreditManager) secureBootstrapIdentityLocked() (string, string, error) {
|
||||
if m.persistence == nil {
|
||||
return "", "", fmt.Errorf("quickstart: persistence unavailable")
|
||||
}
|
||||
|
||||
licensePersistence, err := pkglicensing.NewPersistence(m.persistence.SharedInstallationDataDir())
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("quickstart: load license persistence: %w", err)
|
||||
}
|
||||
activationState, err := licensePersistence.LoadActivationState()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("quickstart: load activation state: %w", err)
|
||||
}
|
||||
if activationState == nil {
|
||||
return "", "", quickstartActivationRequiredError()
|
||||
}
|
||||
|
||||
installationToken := strings.TrimSpace(activationState.InstallationToken)
|
||||
instanceFingerprint := strings.TrimSpace(activationState.InstanceFingerprint)
|
||||
if installationToken == "" || instanceFingerprint == "" {
|
||||
return "", "", quickstartActivationRequiredError()
|
||||
}
|
||||
|
||||
return installationToken, instanceFingerprint, nil
|
||||
}
|
||||
|
||||
func quickstartBlockedReasonFromError(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case providers.IsQuickstartCreditsExhausted(err), quickstartBootstrapCreditsExhausted(err):
|
||||
return patrolQuickstartCreditsExhaustedReason
|
||||
case quickstartBootstrapActivationRequired(err):
|
||||
return patrolQuickstartActivationRequiredReason
|
||||
case providers.IsQuickstartUnavailable(err), quickstartBootstrapUnavailable(err):
|
||||
return patrolQuickstartUnavailableReason
|
||||
default:
|
||||
@@ -387,6 +430,21 @@ func QuickstartUnavailableReason() string {
|
||||
return patrolQuickstartUnavailableReason
|
||||
}
|
||||
|
||||
// QuickstartActivationRequiredReason returns the canonical Patrol quickstart
|
||||
// availability message shown when the install is not activated or trial-backed.
|
||||
func QuickstartActivationRequiredReason() string {
|
||||
return patrolQuickstartActivationRequiredReason
|
||||
}
|
||||
|
||||
func quickstartActivationRequiredError() error {
|
||||
return &pkglicensing.LicenseServerError{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Code: "activation_required",
|
||||
Message: "Quickstart bootstrap requires an activated or trial-backed installation",
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
func quickstartBootstrapCreditsExhausted(err error) bool {
|
||||
var serverErr *pkglicensing.LicenseServerError
|
||||
if !errors.As(err, &serverErr) {
|
||||
@@ -398,6 +456,24 @@ func quickstartBootstrapCreditsExhausted(err error) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(serverErr.Code), "quickstart_credits_exhausted")
|
||||
}
|
||||
|
||||
func quickstartBootstrapActivationRequired(err error) bool {
|
||||
var serverErr *pkglicensing.LicenseServerError
|
||||
if !errors.As(err, &serverErr) {
|
||||
return false
|
||||
}
|
||||
switch serverErr.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return true
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(serverErr.Code)) {
|
||||
case "activation_required", "installation_required", "invalid_installation_token", "invalid_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func quickstartBootstrapUnavailable(err error) bool {
|
||||
var serverErr *pkglicensing.LicenseServerError
|
||||
if errors.As(err, &serverErr) {
|
||||
|
||||
+138
-33
@@ -63,9 +63,6 @@ func TestPersistentQuickstartCreditManager_EnsureBootstrapUsesActivationIdentity
|
||||
if client.bearer != "pit_live_test" {
|
||||
t.Fatalf("bearer = %q, want pit_live_test", client.bearer)
|
||||
}
|
||||
if client.req.ClientInstallationID != "" {
|
||||
t.Fatalf("ClientInstallationID = %q, want empty", client.req.ClientInstallationID)
|
||||
}
|
||||
if client.req.InstanceFingerprint != "fp-live-test" {
|
||||
t.Fatalf("InstanceFingerprint = %q, want fp-live-test", client.req.InstanceFingerprint)
|
||||
}
|
||||
@@ -80,57 +77,53 @@ func TestPersistentQuickstartCreditManager_EnsureBootstrapUsesActivationIdentity
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentQuickstartCreditManager_EnsureBootstrapPersistsCommunityInstallationID(t *testing.T) {
|
||||
func TestPersistentQuickstartCreditManager_EnsureBootstrapRequiresInstallationToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
persistence := config.NewConfigPersistence(dir)
|
||||
|
||||
client := &stubQuickstartBootstrapClient{
|
||||
resp: &pkglicensing.QuickstartBootstrapResponse{
|
||||
QuickstartToken: "qst_live_community",
|
||||
QuickstartTokenExpiresAt: time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339),
|
||||
CreditsRemaining: 17,
|
||||
CreditsTotal: 25,
|
||||
QuickstartToken: "qst_should_not_be_used",
|
||||
CreditsRemaining: 17,
|
||||
CreditsTotal: 25,
|
||||
},
|
||||
}
|
||||
mgr := NewPersistentQuickstartCreditManagerWithClient(persistence, "default", func() *config.AIConfig { return &config.AIConfig{Enabled: true} }, client)
|
||||
mgr.hostname = func() (string, error) { return "pulse-community", nil }
|
||||
mgr.newID = func() string { return "community-install-1" }
|
||||
|
||||
if err := mgr.EnsureBootstrap(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureBootstrap(): %v", err)
|
||||
err := mgr.EnsureBootstrap(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected activation-required error")
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("BootstrapQuickstart calls = %d, want 1", client.calls)
|
||||
if QuickstartBlockedReasonForError(err) != patrolQuickstartActivationRequiredReason {
|
||||
t.Fatalf("blocked reason = %q, want %q", QuickstartBlockedReasonForError(err), patrolQuickstartActivationRequiredReason)
|
||||
}
|
||||
if client.bearer != "" {
|
||||
t.Fatalf("bearer = %q, want empty", client.bearer)
|
||||
if client.calls != 0 {
|
||||
t.Fatalf("BootstrapQuickstart calls = %d, want 0", client.calls)
|
||||
}
|
||||
if client.req.ClientInstallationID != "community-install-1" {
|
||||
t.Fatalf("ClientInstallationID = %q, want community-install-1", client.req.ClientInstallationID)
|
||||
}
|
||||
if client.req.InstanceFingerprint != "community-install-1" {
|
||||
t.Fatalf("InstanceFingerprint = %q, want community-install-1", client.req.InstanceFingerprint)
|
||||
}
|
||||
|
||||
loaded, err := persistence.LoadQuickstartState()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQuickstartState(): %v", err)
|
||||
}
|
||||
if loaded.ClientInstallationID != "community-install-1" {
|
||||
t.Fatalf("persisted ClientInstallationID = %q, want community-install-1", loaded.ClientInstallationID)
|
||||
}
|
||||
if loaded.QuickstartToken != "qst_live_community" {
|
||||
t.Fatalf("persisted QuickstartToken = %q, want qst_live_community", loaded.QuickstartToken)
|
||||
if mgr.CreditsRemaining() != 0 || mgr.CreditsTotal() != 0 {
|
||||
t.Fatalf("credits = %d/%d, want 0/0", mgr.CreditsRemaining(), mgr.CreditsTotal())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentQuickstartCreditManager_LoadsPersistedTokenWithoutBootstrap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
persistence := config.NewConfigPersistence(dir)
|
||||
|
||||
licensePersistence, err := pkglicensing.NewPersistence(persistence.GetConfigDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPersistence(): %v", err)
|
||||
}
|
||||
if err := licensePersistence.SaveActivationState(&pkglicensing.ActivationState{
|
||||
InstallationID: "inst_live_test",
|
||||
InstallationToken: "pit_live_test",
|
||||
InstanceFingerprint: "fp-live-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveActivationState(): %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * time.Minute).UTC().Unix()
|
||||
lastSyncedAt := time.Now().UTC().Unix()
|
||||
if err := persistence.SaveQuickstartState(config.QuickstartState{
|
||||
ClientInstallationID: "community-install-1",
|
||||
QuickstartToken: "qst_live_cached",
|
||||
QuickstartTokenExpiresAt: &expiresAt,
|
||||
QuickstartCreditsRemaining: 11,
|
||||
@@ -162,3 +155,115 @@ func TestPersistentQuickstartCreditManager_LoadsPersistedTokenWithoutBootstrap(t
|
||||
t.Fatal("expected provider from cached quickstart token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentQuickstartCreditManager_ExpiredTokenRebootstrapsWithInstallationToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
persistence := config.NewConfigPersistence(dir)
|
||||
|
||||
licensePersistence, err := pkglicensing.NewPersistence(persistence.GetConfigDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPersistence(): %v", err)
|
||||
}
|
||||
if err := licensePersistence.SaveActivationState(&pkglicensing.ActivationState{
|
||||
InstallationID: "inst_live_test",
|
||||
InstallationToken: "pit_live_test",
|
||||
InstanceFingerprint: "fp-live-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveActivationState(): %v", err)
|
||||
}
|
||||
|
||||
expiredAt := time.Now().Add(-5 * time.Minute).UTC().Unix()
|
||||
lastSyncedAt := time.Now().Add(-10 * time.Minute).UTC().Unix()
|
||||
if err := persistence.SaveQuickstartState(config.QuickstartState{
|
||||
QuickstartToken: "qst_live_expired",
|
||||
QuickstartTokenExpiresAt: &expiredAt,
|
||||
QuickstartCreditsRemaining: 11,
|
||||
QuickstartCreditsTotal: 25,
|
||||
LastSyncedAt: &lastSyncedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuickstartState(): %v", err)
|
||||
}
|
||||
|
||||
client := &stubQuickstartBootstrapClient{
|
||||
resp: &pkglicensing.QuickstartBootstrapResponse{
|
||||
QuickstartToken: "qst_live_rotated",
|
||||
QuickstartTokenExpiresAt: time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339),
|
||||
CreditsRemaining: 9,
|
||||
CreditsTotal: 25,
|
||||
},
|
||||
}
|
||||
mgr := NewPersistentQuickstartCreditManagerWithClient(persistence, "default", func() *config.AIConfig { return &config.AIConfig{Enabled: true} }, client)
|
||||
|
||||
if err := mgr.EnsureBootstrap(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureBootstrap(): %v", err)
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("BootstrapQuickstart calls = %d, want 1", client.calls)
|
||||
}
|
||||
if client.bearer != "pit_live_test" {
|
||||
t.Fatalf("bearer = %q, want pit_live_test", client.bearer)
|
||||
}
|
||||
if client.req.InstanceFingerprint != "fp-live-test" {
|
||||
t.Fatalf("InstanceFingerprint = %q, want fp-live-test", client.req.InstanceFingerprint)
|
||||
}
|
||||
|
||||
loaded, err := persistence.LoadQuickstartState()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQuickstartState(): %v", err)
|
||||
}
|
||||
if loaded.QuickstartToken != "qst_live_rotated" {
|
||||
t.Fatalf("QuickstartToken = %q, want qst_live_rotated", loaded.QuickstartToken)
|
||||
}
|
||||
if loaded.QuickstartCreditsRemaining != 9 {
|
||||
t.Fatalf("QuickstartCreditsRemaining = %d, want 9", loaded.QuickstartCreditsRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentQuickstartCreditManager_TenantUsesSharedInstallationActivationState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mtp := config.NewMultiTenantPersistence(dir)
|
||||
|
||||
defaultPersistence, err := mtp.GetPersistence("default")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPersistence(default): %v", err)
|
||||
}
|
||||
tenantPersistence, err := mtp.GetPersistence("t-tenant")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPersistence(t-tenant): %v", err)
|
||||
}
|
||||
|
||||
licensePersistence, err := pkglicensing.NewPersistence(defaultPersistence.SharedInstallationDataDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPersistence(): %v", err)
|
||||
}
|
||||
if err := licensePersistence.SaveActivationState(&pkglicensing.ActivationState{
|
||||
InstallationID: "inst_live_test",
|
||||
InstallationToken: "pit_live_test",
|
||||
InstanceFingerprint: "fp-live-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveActivationState(): %v", err)
|
||||
}
|
||||
|
||||
client := &stubQuickstartBootstrapClient{
|
||||
resp: &pkglicensing.QuickstartBootstrapResponse{
|
||||
QuickstartToken: "qst_live_tenant",
|
||||
QuickstartTokenExpiresAt: time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339),
|
||||
CreditsRemaining: 14,
|
||||
CreditsTotal: 25,
|
||||
},
|
||||
}
|
||||
mgr := NewPersistentQuickstartCreditManagerWithClient(tenantPersistence, "t-tenant", func() *config.AIConfig { return &config.AIConfig{Enabled: true} }, client)
|
||||
|
||||
if err := mgr.EnsureBootstrap(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureBootstrap(): %v", err)
|
||||
}
|
||||
if client.calls != 1 {
|
||||
t.Fatalf("BootstrapQuickstart calls = %d, want 1", client.calls)
|
||||
}
|
||||
if client.bearer != "pit_live_test" {
|
||||
t.Fatalf("bearer = %q, want pit_live_test", client.bearer)
|
||||
}
|
||||
if mgr.CreditsRemaining() != 14 {
|
||||
t.Fatalf("CreditsRemaining() = %d, want 14", mgr.CreditsRemaining())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,3 +87,28 @@ func TestServiceLoadConfig_TracksQuickstartBootstrapFailure(t *testing.T) {
|
||||
t.Fatal("expected AI service to remain disabled when bootstrap failed and no BYOK exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLoadConfig_TracksQuickstartActivationRequirement(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
persistence := config.NewConfigPersistence(dir)
|
||||
cfg := config.NewDefaultAIConfig()
|
||||
cfg.Enabled = true
|
||||
if err := persistence.SaveAIConfig(*cfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig(): %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(persistence, nil)
|
||||
svc.SetQuickstartCredits(&stubServiceQuickstartManager{
|
||||
ensureErr: &pkglicensing.LicenseServerError{StatusCode: http.StatusUnauthorized, Code: "activation_required", Message: "activation required", Retryable: false},
|
||||
})
|
||||
|
||||
if err := svc.LoadConfig(); err != nil {
|
||||
t.Fatalf("LoadConfig(): %v", err)
|
||||
}
|
||||
if svc.QuickstartBlockedReason() != patrolQuickstartActivationRequiredReason {
|
||||
t.Fatalf("QuickstartBlockedReason() = %q, want %q", svc.QuickstartBlockedReason(), patrolQuickstartActivationRequiredReason)
|
||||
}
|
||||
if svc.IsEnabled() {
|
||||
t.Fatal("expected AI service to remain disabled when activation is required and no BYOK exists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2530,7 +2530,12 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
||||
}
|
||||
}
|
||||
if !hasQuickstart {
|
||||
if strings.TrimSpace(ai.QuickstartBlockedReasonForError(bootstrapErr)) == ai.QuickstartUnavailableReason() {
|
||||
blockedReason := strings.TrimSpace(ai.QuickstartBlockedReasonForError(bootstrapErr))
|
||||
switch blockedReason {
|
||||
case ai.QuickstartActivationRequiredReason():
|
||||
http.Error(w, blockedReason, http.StatusConflict)
|
||||
return
|
||||
case ai.QuickstartUnavailableReason():
|
||||
http.Error(w, ai.QuickstartUnavailableReason(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -76,6 +77,18 @@ func useTestQuickstartBootstrapServer(t *testing.T, assertRequest func(*http.Req
|
||||
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
||||
}
|
||||
|
||||
func persistQuickstartActivationState(t *testing.T, persistence *config.ConfigPersistence) {
|
||||
t.Helper()
|
||||
|
||||
licensePersistence, err := pkglicensing.NewPersistence(persistence.GetConfigDir())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, licensePersistence.SaveActivationState(&pkglicensing.ActivationState{
|
||||
InstallationID: "inst_live_test",
|
||||
InstallationToken: "pit_live_test",
|
||||
InstanceFingerprint: "fp-live-test",
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -227,11 +240,9 @@ func TestAISettingsResponse_UsesCanonicalEmptyCollections(t *testing.T) {
|
||||
func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "true")
|
||||
useTestQuickstartBootstrapServer(t, func(r *http.Request, reqBody map[string]any) {
|
||||
assert.Empty(t, strings.TrimSpace(r.Header.Get("Authorization")))
|
||||
clientInstallationID, _ := reqBody["client_installation_id"].(string)
|
||||
assert.Equal(t, "Bearer pit_live_test", strings.TrimSpace(r.Header.Get("Authorization")))
|
||||
instanceFingerprint, _ := reqBody["instance_fingerprint"].(string)
|
||||
assert.NotEmpty(t, clientInstallationID)
|
||||
assert.Equal(t, clientInstallationID, instanceFingerprint)
|
||||
assert.Equal(t, "fp-live-test", instanceFingerprint)
|
||||
assert.Equal(t, "patrol", reqBody["use_case"])
|
||||
})
|
||||
|
||||
@@ -241,6 +252,7 @@ func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing
|
||||
require.NoError(t, err)
|
||||
|
||||
seedHostedAIBillingState(t, mtp, "default")
|
||||
persistQuickstartActivationState(t, persistence)
|
||||
|
||||
handler := NewAISettingsHandler(mtp, nil, nil)
|
||||
handler.defaultConfig = &config.Config{DataPath: tmp}
|
||||
@@ -265,11 +277,9 @@ func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing
|
||||
func TestAISettingsHandler_GetHostedTenantSettings_InheritsDefaultHostedBillingState(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "true")
|
||||
useTestQuickstartBootstrapServer(t, func(r *http.Request, reqBody map[string]any) {
|
||||
assert.Empty(t, strings.TrimSpace(r.Header.Get("Authorization")))
|
||||
clientInstallationID, _ := reqBody["client_installation_id"].(string)
|
||||
assert.Equal(t, "Bearer pit_live_test", strings.TrimSpace(r.Header.Get("Authorization")))
|
||||
instanceFingerprint, _ := reqBody["instance_fingerprint"].(string)
|
||||
assert.NotEmpty(t, clientInstallationID)
|
||||
assert.Equal(t, clientInstallationID, instanceFingerprint)
|
||||
assert.Equal(t, "fp-live-test", instanceFingerprint)
|
||||
assert.Equal(t, "patrol", reqBody["use_case"])
|
||||
})
|
||||
|
||||
@@ -277,8 +287,11 @@ func TestAISettingsHandler_GetHostedTenantSettings_InheritsDefaultHostedBillingS
|
||||
mtp := config.NewMultiTenantPersistence(tmp)
|
||||
persistence, err := mtp.GetPersistence("t-tenant")
|
||||
require.NoError(t, err)
|
||||
defaultPersistence, err := mtp.GetPersistence("default")
|
||||
require.NoError(t, err)
|
||||
|
||||
seedHostedAIBillingState(t, mtp, "default")
|
||||
persistQuickstartActivationState(t, defaultPersistence)
|
||||
|
||||
handler := NewAISettingsHandler(mtp, nil, nil)
|
||||
handler.defaultConfig = &config.Config{DataPath: tmp}
|
||||
@@ -301,6 +314,28 @@ func TestAISettingsHandler_GetHostedTenantSettings_InheritsDefaultHostedBillingS
|
||||
assert.True(t, persistence.HasAIConfig())
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_UpdateSettings_QuickstartRequiresActivationBeforeEnable(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
handler.defaultAIService.SetQuickstartCredits(ai.NewPersistentQuickstartCreditManager(
|
||||
persistence,
|
||||
"default",
|
||||
func() *config.AIConfig { return &config.AIConfig{Enabled: true} },
|
||||
))
|
||||
|
||||
body, _ := json.Marshal(AISettingsUpdateRequest{
|
||||
Enabled: ptr(true),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusConflict, rec.Code, "body=%s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), ai.QuickstartActivationRequiredReason())
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_UpdateSettings_QuickstartBootstrapBeforeEnable(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
|
||||
@@ -295,6 +295,20 @@ func (c *ConfigPersistence) DataDir() string {
|
||||
return c.configDir
|
||||
}
|
||||
|
||||
// SharedInstallationDataDir returns the installation-scoped config directory
|
||||
// that owns activation and licensing identity. Tenant config directories live
|
||||
// under <base>/orgs/<org>, but activation remains rooted at <base>.
|
||||
func (c *ConfigPersistence) SharedInstallationDataDir() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
sharedDir, err := resolveSharedInstallationDataDir(c.configDir)
|
||||
if err != nil {
|
||||
return c.configDir
|
||||
}
|
||||
return sharedDir
|
||||
}
|
||||
|
||||
// GetConfigDir returns the configuration directory path (alias for DataDir to match interface expectations)
|
||||
func (c *ConfigPersistence) GetConfigDir() string {
|
||||
return c.configDir
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
)
|
||||
|
||||
func resolveSharedInstallationDataDir(configDir string) (string, error) {
|
||||
normalizedConfigDir, err := securityutil.NormalizeStorageDir(ResolveRuntimeDataDir(configDir))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
orgsDir := filepath.Dir(normalizedConfigDir)
|
||||
if filepath.Base(orgsDir) != "orgs" {
|
||||
return normalizedConfigDir, nil
|
||||
}
|
||||
|
||||
orgID := filepath.Base(normalizedConfigDir)
|
||||
if !isValidOrgID(orgID) {
|
||||
return normalizedConfigDir, nil
|
||||
}
|
||||
|
||||
return filepath.Dir(orgsDir), nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfigPersistenceSharedInstallationDataDir_DefaultAndTenantPaths(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
mtp := NewMultiTenantPersistence(baseDir)
|
||||
|
||||
defaultPersistence, err := mtp.GetPersistence("default")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPersistence(default): %v", err)
|
||||
}
|
||||
if got := defaultPersistence.SharedInstallationDataDir(); got != mtp.BaseDataDir() {
|
||||
t.Fatalf("default SharedInstallationDataDir() = %q, want %q", got, mtp.BaseDataDir())
|
||||
}
|
||||
|
||||
tenantPersistence, err := mtp.GetPersistence("acme")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPersistence(acme): %v", err)
|
||||
}
|
||||
if got := tenantPersistence.SharedInstallationDataDir(); got != mtp.BaseDataDir() {
|
||||
t.Fatalf("tenant SharedInstallationDataDir() = %q, want %q", got, mtp.BaseDataDir())
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
// QuickstartState is the machine-owned local cache for the server-authoritative
|
||||
// quickstart bootstrap contract.
|
||||
type QuickstartState struct {
|
||||
ClientInstallationID string `json:"client_installation_id,omitempty"`
|
||||
QuickstartToken string `json:"quickstart_token,omitempty"`
|
||||
QuickstartTokenExpiresAt *int64 `json:"quickstart_token_expires_at,omitempty"`
|
||||
QuickstartCreditsTotal int `json:"quickstart_credits_total,omitempty"`
|
||||
@@ -27,7 +26,6 @@ func NormalizeQuickstartState(state *QuickstartState) *QuickstartState {
|
||||
|
||||
cp := *state
|
||||
normalized := &cp
|
||||
normalized.ClientInstallationID = strings.TrimSpace(normalized.ClientInstallationID)
|
||||
normalized.QuickstartToken = strings.TrimSpace(normalized.QuickstartToken)
|
||||
normalized.QuickstartTokenExpiresAt = cloneQuickstartInt64Ptr(state.QuickstartTokenExpiresAt)
|
||||
normalized.LastSyncedAt = cloneQuickstartInt64Ptr(state.LastSyncedAt)
|
||||
|
||||
@@ -14,7 +14,6 @@ func TestQuickstartStateSaveAndLoad(t *testing.T) {
|
||||
expiresAt := int64(1774449000)
|
||||
lastSyncedAt := int64(1774448000)
|
||||
state := QuickstartState{
|
||||
ClientInstallationID: "community-install-1",
|
||||
QuickstartToken: "qst_live_test_123",
|
||||
QuickstartTokenExpiresAt: &expiresAt,
|
||||
QuickstartCreditsTotal: 25,
|
||||
@@ -30,9 +29,6 @@ func TestQuickstartStateSaveAndLoad(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQuickstartState: %v", err)
|
||||
}
|
||||
if loaded.ClientInstallationID != state.ClientInstallationID {
|
||||
t.Fatalf("ClientInstallationID = %q, want %q", loaded.ClientInstallationID, state.ClientInstallationID)
|
||||
}
|
||||
if loaded.QuickstartToken != state.QuickstartToken {
|
||||
t.Fatalf("QuickstartToken = %q, want %q", loaded.QuickstartToken, state.QuickstartToken)
|
||||
}
|
||||
@@ -68,7 +64,7 @@ func TestLoadQuickstartState_MissingReturnsEmptyState(t *testing.T) {
|
||||
if loaded == nil {
|
||||
t.Fatal("expected empty quickstart state, got nil")
|
||||
}
|
||||
if loaded.QuickstartToken != "" || loaded.QuickstartCreditsRemaining != 0 || loaded.ClientInstallationID != "" {
|
||||
if loaded.QuickstartToken != "" || loaded.QuickstartCreditsRemaining != 0 {
|
||||
t.Fatalf("expected zero-value quickstart state, got %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,8 +142,17 @@ func (c *LicenseServerClient) RefreshGrant(ctx context.Context, installationID,
|
||||
|
||||
// BootstrapQuickstart exchanges a runtime identity for a short-lived
|
||||
// quickstart token and the current authoritative quickstart inventory.
|
||||
// Activated installs should pass their installation token as bearerToken.
|
||||
// Callers must pass a server-verified installation token as bearerToken.
|
||||
func (c *LicenseServerClient) BootstrapQuickstart(ctx context.Context, bearerToken string, req QuickstartBootstrapRequest) (*QuickstartBootstrapResponse, error) {
|
||||
if strings.TrimSpace(bearerToken) == "" {
|
||||
return nil, &LicenseServerError{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Code: "activation_required",
|
||||
Message: "Quickstart bootstrap requires an installation token",
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal quickstart bootstrap request: %w", err)
|
||||
@@ -154,9 +163,7 @@ func (c *LicenseServerClient) BootstrapQuickstart(ctx context.Context, bearerTok
|
||||
return nil, fmt.Errorf("create quickstart bootstrap request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if token := strings.TrimSpace(bearerToken); token != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearerToken))
|
||||
|
||||
bootstrapClient := *c.httpClient
|
||||
bootstrapClient.Timeout = 10 * time.Second
|
||||
|
||||
@@ -392,40 +392,29 @@ func TestClientBootstrapQuickstart(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses community installation id when no auth token is present", func(t *testing.T) {
|
||||
var seenAuthorization string
|
||||
var seenRequest QuickstartBootstrapRequest
|
||||
|
||||
t.Run("requires installation bearer token", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenAuthorization = r.Header.Get("Authorization")
|
||||
if err := json.NewDecoder(r.Body).Decode(&seenRequest); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
json.NewEncoder(w).Encode(QuickstartBootstrapResponse{
|
||||
QuickstartToken: "qst_live_community",
|
||||
CreditsRemaining: 25,
|
||||
CreditsTotal: 25,
|
||||
})
|
||||
t.Fatal("bootstrap request should not reach the server without an installation token")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewLicenseServerClient(server.URL)
|
||||
resp, err := client.BootstrapQuickstart(context.Background(), "", QuickstartBootstrapRequest{
|
||||
ClientInstallationID: "community-install-1",
|
||||
InstanceFingerprint: "community-install-1",
|
||||
UseCase: "patrol",
|
||||
_, err := client.BootstrapQuickstart(context.Background(), "", QuickstartBootstrapRequest{
|
||||
InstanceFingerprint: "fp-123",
|
||||
UseCase: "patrol",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapQuickstart failed: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing installation token error")
|
||||
}
|
||||
if seenAuthorization != "" {
|
||||
t.Fatalf("Authorization = %q, want empty", seenAuthorization)
|
||||
apiErr, ok := err.(*LicenseServerError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *LicenseServerError, got %T", err)
|
||||
}
|
||||
if seenRequest.ClientInstallationID != "community-install-1" {
|
||||
t.Fatalf("ClientInstallationID = %q, want community-install-1", seenRequest.ClientInstallationID)
|
||||
if apiErr.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
if resp.QuickstartToken != "qst_live_community" {
|
||||
t.Fatalf("QuickstartToken = %q, want qst_live_community", resp.QuickstartToken)
|
||||
if apiErr.Code != "activation_required" {
|
||||
t.Fatalf("Code = %q, want activation_required", apiErr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ package licensing
|
||||
// QuickstartBootstrapRequest is sent to the license server to exchange a
|
||||
// runtime identity for a server-issued quickstart token.
|
||||
//
|
||||
// Activated installs authenticate the request with their installation token.
|
||||
// Community installs omit auth and instead send a stable client installation id.
|
||||
// Activated or hosted/trial-backed installs authenticate the request with
|
||||
// their installation token. The request body supplies only binding metadata.
|
||||
type QuickstartBootstrapRequest struct {
|
||||
ClientInstallationID string `json:"client_installation_id,omitempty"`
|
||||
InstanceFingerprint string `json:"instance_fingerprint,omitempty"`
|
||||
InstanceName string `json:"instance_name,omitempty"`
|
||||
UseCase string `json:"use_case,omitempty"`
|
||||
InstanceFingerprint string `json:"instance_fingerprint,omitempty"`
|
||||
InstanceName string `json:"instance_name,omitempty"`
|
||||
UseCase string `json:"use_case,omitempty"`
|
||||
}
|
||||
|
||||
// QuickstartBootstrapResponse contains the server-issued quickstart token and
|
||||
|
||||
Reference in New Issue
Block a user