From e80c047a26454128e4a2dcf6460040e3a3ba33eb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 13 Apr 2026 19:28:25 +0800 Subject: [PATCH] Simplify xworkmate account sync contract --- api/api.go | 14 +- api/xworkmate.go | 57 ++++++ api/xworkmate_bridge_bootstrap.go | 319 ------------------------------ api/xworkmate_test.go | 220 +++++++++++++-------- 4 files changed, 195 insertions(+), 415 deletions(-) delete mode 100644 api/xworkmate_bridge_bootstrap.go diff --git a/api/api.go b/api/api.go index c763091..1f8d677 100644 --- a/api/api.go +++ b/api/api.go @@ -40,8 +40,6 @@ const defaultPasswordResetTTL = 30 * time.Minute const maxMFAVerificationAttempts = 5 const defaultMFALockoutDuration = 5 * time.Minute const defaultOAuthExchangeCodeTTL = 5 * time.Minute -const defaultBridgeBootstrapTTL = 5 * time.Minute -const defaultBridgeBootstrapTarget = "https://xworkmate-bridge.svc.plus" const sessionCookieName = "xc_session" @@ -84,10 +82,6 @@ type handler struct { oauthExchangeCodes map[string]oauthExchangeCode oauthExchangeMu sync.RWMutex oauthExchangeTTL time.Duration - bridgeBootstrapTickets map[string]bridgeBootstrapTicket - bridgeBootstrapByCode map[string]string - bridgeBootstrapMu sync.RWMutex - bridgeBootstrapTTL time.Duration metricsProvider service.UserMetricsProvider agentStatusReader agentStatusReader tokenService *auth.TokenService @@ -317,9 +311,6 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { passwordResets: make(map[string]passwordReset), oauthExchangeCodes: make(map[string]oauthExchangeCode), oauthExchangeTTL: defaultOAuthExchangeCodeTTL, - bridgeBootstrapTickets: make(map[string]bridgeBootstrapTicket), - bridgeBootstrapByCode: make(map[string]string), - bridgeBootstrapTTL: defaultBridgeBootstrapTTL, } for _, opt := range opts { @@ -385,13 +376,11 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { authProtected.GET("/session", h.session) authProtected.DELETE("/session", h.deleteSession) authProtected.GET("/xworkmate/profile", h.getXWorkmateProfile) + authProtected.GET("/xworkmate/profile/sync", h.getXWorkmateProfileSync) authProtected.PUT("/xworkmate/profile", h.updateXWorkmateProfile) authProtected.GET("/xworkmate/secrets", h.getXWorkmateSecrets) authProtected.PUT("/xworkmate/secrets/:target", h.putXWorkmateSecret) authProtected.DELETE("/xworkmate/secrets/:target", h.deleteXWorkmateSecret) - authProtected.POST("/xworkmate/bridge/bootstrap", h.createXWorkmateBridgeBootstrapTicket) - authProtected.GET("/xworkmate/bridge/bootstrap/:shortCode", h.lookupXWorkmateBridgeBootstrapTicket) - authProtected.POST("/xworkmate/bridge/bootstrap/:ticketId/revoke", h.revokeXWorkmateBridgeBootstrapTicket) authProtected.POST("/mfa/totp/provision", h.provisionTOTP) authProtected.POST("/mfa/totp/verify", h.verifyTOTP) @@ -448,7 +437,6 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { internalGroup.GET("/network/identities", h.internalNetworkIdentities) internalGroup.GET("/policy/:accountUUID", h.internalAccountPolicy) internalGroup.POST("/nodes/heartbeat", h.internalNodeHeartbeat) - internalGroup.POST("/xworkmate/bridge/bootstrap/consume", h.internalConsumeXWorkmateBridgeBootstrapTicket) // Public /api routes for admin/management (expected by frontend at /api/admin/...) apiGroup := r.Group("/api") diff --git a/api/xworkmate.go b/api/xworkmate.go index 8666a62..da3d0d2 100644 --- a/api/xworkmate.go +++ b/api/xworkmate.go @@ -618,6 +618,63 @@ func (h *handler) getXWorkmateProfile(c *gin.Context) { c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, profile, tokenConfigured)) } +func (h *handler) getXWorkmateProfileSync(c *gin.Context) { + user, ok := h.currentAuthenticatedUser(c) + if !ok { + return + } + + access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user) + if err != nil { + if errors.Is(err, store.ErrTenantMembershipNotFound) { + respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required") + return + } + if errors.Is(err, store.ErrTenantNotFound) { + respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found") + return + } + respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context") + return + } + + profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user) + if err != nil { + respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile") + return + } + + bridgeServerURL := "" + if profile != nil { + bridgeServerURL = strings.TrimSpace(profile.BridgeServerURL) + } + if bridgeServerURL == "" { + respondError(c, http.StatusConflict, "bridge_server_url_unavailable", "bridge server url is unavailable") + return + } + if h.xworkmateVaultService == nil { + respondError(c, http.StatusConflict, "bridge_auth_token_unavailable", "bridge auth token is unavailable") + return + } + + locator, ok := findStoredXWorkmateSecretLocator(profile, store.XWorkmateSecretLocatorTargetBridgeAuthToken) + if !ok { + respondError(c, http.StatusConflict, "bridge_auth_token_unavailable", "bridge auth token is unavailable") + return + } + + bridgeAuthToken, err := h.xworkmateVaultService.ReadSecret(c.Request.Context(), locator) + if err != nil || strings.TrimSpace(bridgeAuthToken) == "" { + respondError(c, http.StatusConflict, "bridge_auth_token_unavailable", "bridge auth token is unavailable") + return + } + + c.JSON(http.StatusOK, gin.H{ + "BRIDGE_SERVER_URL": bridgeServerURL, + "BRIDGE_AUTH_TOKEN": strings.TrimSpace(bridgeAuthToken), + }) +} + func (h *handler) updateXWorkmateProfile(c *gin.Context) { user, ok := h.currentAuthenticatedUser(c) if !ok { diff --git a/api/xworkmate_bridge_bootstrap.go b/api/xworkmate_bridge_bootstrap.go deleted file mode 100644 index adc77a1..0000000 --- a/api/xworkmate_bridge_bootstrap.go +++ /dev/null @@ -1,319 +0,0 @@ -package api - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "account/internal/auth" - "account/internal/store" -) - -const ( - bridgeBootstrapScheme = "xworkmate-bridge-bootstrap" - bridgeBootstrapScopeA = "connect" - bridgeBootstrapScopeB = "pairing.bootstrap" - bridgeBootstrapShortLen = 8 -) - -type bridgeBootstrapTicket struct { - TicketID string - ShortCode string - TenantID string - UserID string - ProfileScope string - TargetBridge string - Scopes []string - ExpiresAt time.Time - OneTime bool - IssuedAt time.Time - ConsumedAt time.Time - RevokedAt time.Time -} - -type bridgeBootstrapIssueResponse struct { - Ticket string `json:"ticket"` - ShortCode string `json:"shortCode"` - Bridge string `json:"bridge"` - Scheme string `json:"scheme"` - ExpiresAt string `json:"expiresAt"` - Scopes []string `json:"scopes"` - OneTime bool `json:"oneTime"` - QRPayload string `json:"qrPayload"` -} - -type bridgeBootstrapConsumeResponse struct { - TicketID string `json:"ticketId"` - TargetBridge string `json:"targetBridge"` - BridgeServerURL string `json:"BRIDGE_SERVER_URL"` - AuthMode string `json:"authMode"` - BridgeAuthToken string `json:"BRIDGE_AUTH_TOKEN"` - ExpiresAt string `json:"expiresAt"` - Scopes []string `json:"scopes"` -} - -func sanitizeBridgeTarget(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return defaultBridgeBootstrapTarget - } - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" { - return defaultBridgeBootstrapTarget - } - return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) -} - -func newBridgeBootstrapQRCode(ticketID, bridge string) string { - payload, _ := json.Marshal(gin.H{ - "scheme": bridgeBootstrapScheme, - "ticket": ticketID, - "bridge": bridge, - }) - return string(payload) -} - -func newBridgeBootstrapShortCode() string { - for { - raw := make([]byte, 6) - _, _ = rand.Read(raw) - code := strings.ToUpper( - strings.TrimRight(base64.RawURLEncoding.EncodeToString(raw), "="), - ) - code = strings.NewReplacer("-", "A", "_", "B").Replace(code) - if len(code) >= bridgeBootstrapShortLen { - return code[:bridgeBootstrapShortLen] - } - } -} - -func (h *handler) storeBridgeBootstrapTicket(ticket bridgeBootstrapTicket) { - h.bridgeBootstrapMu.Lock() - defer h.bridgeBootstrapMu.Unlock() - h.bridgeBootstrapTickets[ticket.TicketID] = ticket - h.bridgeBootstrapByCode[strings.ToUpper(ticket.ShortCode)] = ticket.TicketID -} - -func (h *handler) loadBridgeBootstrapTicketByID(ticketID string) (bridgeBootstrapTicket, bool) { - h.bridgeBootstrapMu.RLock() - defer h.bridgeBootstrapMu.RUnlock() - ticket, ok := h.bridgeBootstrapTickets[strings.TrimSpace(ticketID)] - return ticket, ok -} - -func (h *handler) loadBridgeBootstrapTicketByCode(shortCode string) (bridgeBootstrapTicket, bool) { - h.bridgeBootstrapMu.RLock() - defer h.bridgeBootstrapMu.RUnlock() - ticketID, ok := h.bridgeBootstrapByCode[strings.ToUpper(strings.TrimSpace(shortCode))] - if !ok { - return bridgeBootstrapTicket{}, false - } - ticket, ok := h.bridgeBootstrapTickets[ticketID] - return ticket, ok -} - -func (h *handler) updateBridgeBootstrapTicket(ticket bridgeBootstrapTicket) { - h.bridgeBootstrapMu.Lock() - defer h.bridgeBootstrapMu.Unlock() - h.bridgeBootstrapTickets[ticket.TicketID] = ticket - if ticket.ShortCode != "" { - h.bridgeBootstrapByCode[strings.ToUpper(ticket.ShortCode)] = ticket.TicketID - } -} - -func bridgeBootstrapExpired(ticket bridgeBootstrapTicket, now time.Time) bool { - return ticket.ExpiresAt.IsZero() || now.After(ticket.ExpiresAt) -} - -func requireBridgeBootstrapAccess(c *gin.Context, h *handler) (*store.User, *xworkmateAccessContext, bool) { - user, ok := h.currentAuthenticatedUser(c) - if !ok { - return nil, nil, false - } - access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user) - if err != nil { - respondError(c, http.StatusForbidden, "tenant_access_denied", "failed to resolve tenant access") - return nil, nil, false - } - return user, access, true -} - -func (h *handler) createXWorkmateBridgeBootstrapTicket(c *gin.Context) { - user, access, ok := requireBridgeBootstrapAccess(c, h) - if !ok { - return - } - if !auth.IsMFAVerified(c) { - respondError(c, http.StatusForbidden, "mfa_required", "mfa verification required") - return - } - - var req struct { - TargetBridge string `json:"targetBridge"` - } - _ = c.ShouldBindJSON(&req) - - now := time.Now().UTC() - ticket := bridgeBootstrapTicket{ - TicketID: generateRandomState(), - ShortCode: newBridgeBootstrapShortCode(), - TenantID: strings.TrimSpace(access.Tenant.ID), - UserID: strings.TrimSpace(user.ID), - ProfileScope: access.ProfileScope, - TargetBridge: sanitizeBridgeTarget(req.TargetBridge), - Scopes: []string{bridgeBootstrapScopeA, bridgeBootstrapScopeB}, - ExpiresAt: now.Add(h.bridgeBootstrapTTL), - OneTime: true, - IssuedAt: now, - } - h.storeBridgeBootstrapTicket(ticket) - - c.JSON(http.StatusOK, bridgeBootstrapIssueResponse{ - Ticket: ticket.TicketID, - ShortCode: ticket.ShortCode, - Bridge: ticket.TargetBridge, - Scheme: bridgeBootstrapScheme, - ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339), - Scopes: append([]string(nil), ticket.Scopes...), - OneTime: ticket.OneTime, - QRPayload: newBridgeBootstrapQRCode(ticket.TicketID, ticket.TargetBridge), - }) -} - -func (h *handler) lookupXWorkmateBridgeBootstrapTicket(c *gin.Context) { - user, access, ok := requireBridgeBootstrapAccess(c, h) - if !ok { - return - } - ticket, found := h.loadBridgeBootstrapTicketByCode(c.Param("shortCode")) - if !found || ticket.UserID != strings.TrimSpace(user.ID) || ticket.TenantID != strings.TrimSpace(access.Tenant.ID) { - respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found") - return - } - if bridgeBootstrapExpired(ticket, time.Now().UTC()) { - respondError(c, http.StatusGone, "bridge_bootstrap_expired", "bridge bootstrap ticket expired") - return - } - if !ticket.RevokedAt.IsZero() { - respondError(c, http.StatusGone, "bridge_bootstrap_revoked", "bridge bootstrap ticket revoked") - return - } - c.JSON(http.StatusOK, bridgeBootstrapIssueResponse{ - Ticket: ticket.TicketID, - ShortCode: ticket.ShortCode, - Bridge: ticket.TargetBridge, - Scheme: bridgeBootstrapScheme, - ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339), - Scopes: append([]string(nil), ticket.Scopes...), - OneTime: ticket.OneTime, - QRPayload: newBridgeBootstrapQRCode(ticket.TicketID, ticket.TargetBridge), - }) -} - -func (h *handler) revokeXWorkmateBridgeBootstrapTicket(c *gin.Context) { - user, access, ok := requireBridgeBootstrapAccess(c, h) - if !ok { - return - } - ticket, found := h.loadBridgeBootstrapTicketByID(c.Param("ticketId")) - if !found || ticket.UserID != strings.TrimSpace(user.ID) || ticket.TenantID != strings.TrimSpace(access.Tenant.ID) { - respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found") - return - } - ticket.RevokedAt = time.Now().UTC() - h.updateBridgeBootstrapTicket(ticket) - c.JSON(http.StatusOK, gin.H{"ok": true, "ticketId": ticket.TicketID, "revokedAt": ticket.RevokedAt.Format(time.RFC3339)}) -} - -func (h *handler) internalConsumeXWorkmateBridgeBootstrapTicket(c *gin.Context) { - if !h.ensureXWorkmateVaultService(c) { - return - } - var req struct { - Ticket string `json:"ticket"` - Bridge string `json:"bridge"` - } - if err := c.ShouldBindJSON(&req); err != nil { - respondError(c, http.StatusBadRequest, "invalid_request", "invalid bridge bootstrap consume request") - return - } - ticket, found := h.loadBridgeBootstrapTicketByID(req.Ticket) - if !found { - respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found") - return - } - now := time.Now().UTC() - if bridgeBootstrapExpired(ticket, now) { - respondError(c, http.StatusGone, "bridge_bootstrap_expired", "bridge bootstrap ticket expired") - return - } - if !ticket.RevokedAt.IsZero() { - respondError(c, http.StatusGone, "bridge_bootstrap_revoked", "bridge bootstrap ticket revoked") - return - } - if ticket.OneTime && !ticket.ConsumedAt.IsZero() { - respondError(c, http.StatusConflict, "bridge_bootstrap_consumed", "bridge bootstrap ticket already consumed") - return - } - if sanitizeBridgeTarget(req.Bridge) != ticket.TargetBridge { - respondError(c, http.StatusForbidden, "bridge_bootstrap_target_mismatch", "bridge bootstrap target mismatch") - return - } - - ctx := c.Request.Context() - profile, err := h.loadXWorkmateProfile( - ctx, - &xworkmateAccessContext{ - ProfileScope: ticket.ProfileScope, - Tenant: &store.Tenant{ - ID: ticket.TenantID, - }, - }, - &store.User{ - ID: ticket.UserID, - }, - ) - if err != nil { - respondError(c, http.StatusInternalServerError, "xworkmate_profile_unavailable", "failed to load xworkmate profile") - return - } - if profile == nil { - respondError(c, http.StatusNotFound, "xworkmate_profile_not_found", "xworkmate profile not found") - return - } - locator, ok := findStoredXWorkmateSecretLocator(profile, store.XWorkmateSecretLocatorTargetBridgeAuthToken) - if !ok { - respondError(c, http.StatusConflict, "gateway_token_not_configured", "gateway token is not configured") - return - } - gatewayToken, err := h.xworkmateVaultService.ReadSecret(ctx, locator) - if err != nil || strings.TrimSpace(gatewayToken) == "" { - respondError(c, http.StatusConflict, "gateway_token_unavailable", "gateway token is unavailable") - return - } - bridgeServerURL := strings.TrimSpace(profile.BridgeServerURL) - if bridgeServerURL == "" { - respondError(c, http.StatusConflict, "gateway_endpoint_not_configured", "gateway endpoint is not configured") - return - } - - ticket.ConsumedAt = now - h.updateBridgeBootstrapTicket(ticket) - - c.JSON(http.StatusOK, bridgeBootstrapConsumeResponse{ - TicketID: ticket.TicketID, - TargetBridge: ticket.TargetBridge, - BridgeServerURL: bridgeServerURL, - AuthMode: "shared-token", - BridgeAuthToken: gatewayToken, - ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339), - Scopes: append([]string(nil), ticket.Scopes...), - }) -} diff --git a/api/xworkmate_test.go b/api/xworkmate_test.go index 851de05..e1c05ff 100644 --- a/api/xworkmate_test.go +++ b/api/xworkmate_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "net/http/httptest" "strings" @@ -155,11 +154,11 @@ func TestBuildXWorkmateTokenConfiguredUsesSecretLocators(t *testing.T) { } } -func TestXWorkmateBridgeBootstrapTicketLifecycle(t *testing.T) { +func TestGetXWorkmateProfileSyncReturnsManagedBridgeCredentials(t *testing.T) { gin.SetMode(gin.TestMode) vaultService := newMemoryXWorkmateVaultService() - router, user, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService) + router, _, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService) profileBody, err := json.Marshal(map[string]any{ "profile": map[string]any{ @@ -198,95 +197,150 @@ func TestXWorkmateBridgeBootstrapTicketLifecycle(t *testing.T) { t.Fatalf("write secret: %v", err) } - createReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`))) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+token) - createReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) - createRec := httptest.NewRecorder() - router.ServeHTTP(createRec, createReq) - if createRec.Code != http.StatusOK { - t.Fatalf("expected bootstrap create success, got %d: %s", createRec.Code, createRec.Body.String()) - } - var created bridgeBootstrapIssueResponse - if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { - t.Fatalf("decode bootstrap create response: %v", err) - } - if created.Ticket == "" || created.ShortCode == "" { - t.Fatalf("expected bootstrap ticket and short code, got %#v", created) + req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected profile sync success, got %d: %s", rec.Code, rec.Body.String()) } - lookupReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/bridge/bootstrap/"+created.ShortCode, nil) - lookupReq.Header.Set("Authorization", "Bearer "+token) - lookupReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) - lookupRec := httptest.NewRecorder() - router.ServeHTTP(lookupRec, lookupReq) - if lookupRec.Code != http.StatusOK { - t.Fatalf("expected bootstrap lookup success, got %d: %s", lookupRec.Code, lookupRec.Body.String()) + var payload struct { + BridgeServerURL string `json:"BRIDGE_SERVER_URL"` + BridgeAuthToken string `json:"BRIDGE_AUTH_TOKEN"` } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode profile sync response: %v", err) + } + if payload.BridgeServerURL != "wss://openclaw.example.com" { + t.Fatalf("expected bridge server url, got %#v", payload) + } + if payload.BridgeAuthToken != "shared-token-value" { + t.Fatalf("expected bridge auth token, got %#v", payload) + } +} - consumeReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, created.Ticket, created.Bridge)))) - consumeReq.Header.Set("Content-Type", "application/json") - consumeReq.Header.Set("X-Service-Token", "internal-test-token") - t.Setenv("INTERNAL_SERVICE_TOKEN", "internal-test-token") - consumeRec := httptest.NewRecorder() - router.ServeHTTP(consumeRec, consumeReq) - if consumeRec.Code != http.StatusOK { - t.Fatalf("expected bootstrap consume success, got %d: %s", consumeRec.Code, consumeRec.Body.String()) - } - var consumed bridgeBootstrapConsumeResponse - if err := json.Unmarshal(consumeRec.Body.Bytes(), &consumed); err != nil { - t.Fatalf("decode bootstrap consume response: %v", err) - } - if consumed.BridgeAuthToken != "shared-token-value" { - t.Fatalf("expected returned bridge auth token, got %#v", consumed) - } - if consumed.BridgeServerURL != "wss://openclaw.example.com" { - t.Fatalf("expected returned bridge server url, got %#v", consumed) - } +func TestGetXWorkmateProfileSyncConflictsWhenManagedBridgeContractMissing(t *testing.T) { + gin.SetMode(gin.TestMode) - replayReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, created.Ticket, created.Bridge)))) - replayReq.Header.Set("Content-Type", "application/json") - replayReq.Header.Set("X-Service-Token", "internal-test-token") - replayRec := httptest.NewRecorder() - router.ServeHTTP(replayRec, replayReq) - if replayRec.Code != http.StatusConflict { - t.Fatalf("expected bootstrap replay conflict, got %d: %s", replayRec.Code, replayRec.Body.String()) - } + t.Run("missing bridge server url", func(t *testing.T) { + vaultService := newMemoryXWorkmateVaultService() + router, _, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService) - revokeCreateReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`))) - revokeCreateReq.Header.Set("Content-Type", "application/json") - revokeCreateReq.Header.Set("Authorization", "Bearer "+token) - revokeCreateReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) - revokeCreateRec := httptest.NewRecorder() - router.ServeHTTP(revokeCreateRec, revokeCreateReq) - if revokeCreateRec.Code != http.StatusOK { - t.Fatalf("expected second bootstrap create success, got %d: %s", revokeCreateRec.Code, revokeCreateRec.Body.String()) - } - var revokeCreated bridgeBootstrapIssueResponse - if err := json.Unmarshal(revokeCreateRec.Body.Bytes(), &revokeCreated); err != nil { - t.Fatalf("decode revoke bootstrap create response: %v", err) - } + profileBody, err := json.Marshal(map[string]any{ + "profile": map[string]any{ + "secretLocators": []map[string]any{ + { + "id": "locator-openclaw", + "provider": "vault", + "secretPath": "kv/openclaw", + "secretKey": "token", + "target": store.XWorkmateSecretLocatorTargetBridgeAuthToken, + "required": true, + }, + }, + }, + }) + if err != nil { + t.Fatalf("marshal profile: %v", err) + } - revokeReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap/"+revokeCreated.Ticket+"/revoke", nil) - revokeReq.Header.Set("Authorization", "Bearer "+token) - revokeReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) - revokeRec := httptest.NewRecorder() - router.ServeHTTP(revokeRec, revokeReq) - if revokeRec.Code != http.StatusOK { - t.Fatalf("expected bootstrap revoke success, got %d: %s", revokeRec.Code, revokeRec.Body.String()) - } + putReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody)) + putReq.Header.Set("Content-Type", "application/json") + putReq.Header.Set("Authorization", "Bearer "+token) + putReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + putRec := httptest.NewRecorder() + router.ServeHTTP(putRec, putReq) + if putRec.Code != http.StatusOK { + t.Fatalf("expected profile update success, got %d: %s", putRec.Code, putRec.Body.String()) + } - revokedConsumeReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, revokeCreated.Ticket, revokeCreated.Bridge)))) - revokedConsumeReq.Header.Set("Content-Type", "application/json") - revokedConsumeReq.Header.Set("X-Service-Token", "internal-test-token") - revokedConsumeRec := httptest.NewRecorder() - router.ServeHTTP(revokedConsumeRec, revokedConsumeReq) - if revokedConsumeRec.Code != http.StatusGone { - t.Fatalf("expected revoked bootstrap consume gone, got %d: %s", revokedConsumeRec.Code, revokedConsumeRec.Body.String()) - } + if err := vaultService.WriteSecret(context.Background(), store.XWorkmateSecretLocator{ + Provider: "vault", + SecretPath: "kv/openclaw", + SecretKey: "token", + Target: store.XWorkmateSecretLocatorTargetBridgeAuthToken, + }, "shared-token-value"); err != nil { + t.Fatalf("write secret: %v", err) + } - if user.ID == "" { - t.Fatalf("expected created user id") + req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("expected profile sync conflict, got %d: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("missing bridge auth token", func(t *testing.T) { + vaultService := newMemoryXWorkmateVaultService() + router, _, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService) + + profileBody, err := json.Marshal(map[string]any{ + "profile": map[string]any{ + "BRIDGE_SERVER_URL": "wss://openclaw.example.com", + }, + }) + if err != nil { + t.Fatalf("marshal profile: %v", err) + } + + putReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody)) + putReq.Header.Set("Content-Type", "application/json") + putReq.Header.Set("Authorization", "Bearer "+token) + putReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + putRec := httptest.NewRecorder() + router.ServeHTTP(putRec, putReq) + if putRec.Code != http.StatusOK { + t.Fatalf("expected profile update success, got %d: %s", putRec.Code, putRec.Body.String()) + } + + req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("expected profile sync conflict, got %d: %s", rec.Code, rec.Body.String()) + } + }) +} + +func TestXWorkmateBridgeBootstrapRoutesRemoved(t *testing.T) { + gin.SetMode(gin.TestMode) + + router, _, token := newXWorkmateTestHarness(t) + requests := []*http.Request{ + httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`))), + httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/bridge/bootstrap/SHORTCODE", nil), + httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap/ticket-id/revoke", nil), + httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(`{}`))), + } + for _, req := range requests { + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected route removal 404 for %s %s, got %d: %s", req.Method, req.URL.Path, rec.Code, rec.Body.String()) + } + } +} + +func TestGetXWorkmateProfileSyncRequiresSession(t *testing.T) { + gin.SetMode(gin.TestMode) + + router, _, _ := newXWorkmateTestHarness(t) + req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", nil) + req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected profile sync unauthorized without session, got %d: %s", rec.Code, rec.Body.String()) } }