feat(integrations): expose oauth callbacks
This commit is contained in:
@@ -35,6 +35,9 @@ var criticalRoutes = []route{
|
||||
{Method: "POST", Path: "/twilio/voice/status/:phone", Controller: "twilio/voice#status", Source: "routes.rb:644"},
|
||||
{Method: "POST", Path: "/twilio/voice/conference_status/:phone", Controller: "twilio/voice#conference_status", Source: "routes.rb:645"},
|
||||
{Method: "POST", Path: "/twilio/voice/recording_status/:phone", Controller: "twilio/voice#recording_status", Source: "routes.rb:646"},
|
||||
{Method: "GET", Path: "/linear/callback", Controller: "linear/callbacks#show", Source: "routes.rb:630"},
|
||||
{Method: "GET", Path: "/shopify/callback", Controller: "shopify/callbacks#show", Source: "routes.rb:634"},
|
||||
{Method: "GET", Path: "/notion/callback", Controller: "notion/callbacks#show", Source: "routes.rb:654"},
|
||||
|
||||
{Method: "POST", Path: "/api/v1/accounts/", Controller: "api/v1/accounts#create", Source: "routes.rb:47"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id", Controller: "api/v1/accounts#show", Source: "routes.rb:47"},
|
||||
|
||||
@@ -49,14 +49,14 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(twilio): expose root callback`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(twilio): expose root callback`.
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(integrations): expose oauth callbacks`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): expose oauth callbacks`.
|
||||
- Latest documentation/tooling checkpoint: `docs: land full parity tracking plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Worktree status at this implementation checkpoint: P2/P3 external service verification route drift is now implemented for Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `CustomDomainsController#verify`, and `routes.rb:657-660`; Twilio callback route drift is implemented for `Twilio::CallbackController`, `Twilio::DeliveryStatusController`, and `routes.rb:639-640`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, `/.well-known/cf-custom-hostname-challenge/:id`, `/twilio/callback`, `/twilio/delivery_status`, plus `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`. Twilio root callbacks acknowledge with `204 No Content`, resolve channels by `To` or `MessagingServiceSid`, and persist inbound messages through the existing provider incoming boundary; Twilio voice callbacks resolve the `Channel::TwilioSms` phone/inbox, enforce `voice_enabled`, return conference TwiML, and persist status/conference/recording payloads against stored Twilio call rows. P2/P3 dashboard deep-link route drift is in Review. P3.8/P6.6l public help-center sitemap drift is in Review. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, P3.5 company avatar form/pagination drift, P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 2/3 callback drift slice, Phase 6 placeholder burn-down item, or B12 live smoke finding.
|
||||
- Worktree status at this implementation checkpoint: P2/P3 external service verification route drift is now implemented for Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `CustomDomainsController#verify`, and `routes.rb:657-660`; Twilio callback route drift is implemented for `Twilio::CallbackController`, `Twilio::DeliveryStatusController`, and `routes.rb:639-640`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`; root integration callback drift is implemented for `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, and `routes.rb:630/634/654`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, `/.well-known/cf-custom-hostname-challenge/:id`, `/twilio/callback`, `/twilio/delivery_status`, `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, `/twilio/voice/recording_status/:phone`, `/linear/callback`, `/shopify/callback`, and `/notion/callback`. Linear, Shopify, and Notion callbacks verify account state, exchange OAuth codes through provider token endpoints, persist Chatwoot-style account integration hooks, and redirect to reused frontend integration settings; Shopify account integration auth now returns the Chatwoot `redirect_url` authorization payload instead of creating credentials directly. P2/P3 dashboard deep-link route drift is in Review. P3.8/P6.6l public help-center sitemap drift is in Review. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, P3.5 company avatar form/pagination drift, P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder burn-down, or B12 live smoke finding.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 892` after adding the Chatwoot root Twilio callback route.
|
||||
- Route dump succeeds with `TOTAL: 895` after adding Chatwoot root Linear, Shopify, and Notion callback routes.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
- Tracked frontend-critical route audit covers 354 Chatwoot routes: 345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
|
||||
- Tracked frontend-critical route audit covers 357 Chatwoot routes: 348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher.
|
||||
- `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers.
|
||||
- Handler test stability fixes are committed into the baseline before feature parity work continues.
|
||||
- `.codegraph/` is generated indexing output and is not part of tracked product code.
|
||||
@@ -138,7 +138,7 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
|
||||
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 354-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Run the next evidence-backed route/controller/serializer drift audit. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 357-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok or the next evidence-backed route/controller/serializer drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. |
|
||||
| 3 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 4 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
@@ -207,6 +207,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(integrations): expose oauth callbacks` | Advances Phase 2/3 root integration callback parity by matching Chatwoot `Linear::CallbacksController#show`, `Shopify::CallbacksController#show`, `Notion::CallbacksController#show`, account Shopify auth behavior, and `routes.rb:630/634/654`. GoChat now registers `/linear/callback`, `/shopify/callback`, and `/notion/callback`; verifies account state, exchanges OAuth codes through configurable provider token endpoints, persists account integration hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify auth `redirect_url` authorization payloads. | `go test ./internal/router -run 'IntegrationCallbacks\|RegisterRoutes' -count=1`; `go test ./internal/service -run ShopifyIntegrationService -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 895`; tracked route parity is `348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 357`. | Linear/Shopify/Notion root callbacks move to Review for current reference evidence; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(twilio): expose root callback` | Advances Phase 2/3 Twilio ingress parity by matching Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and `routes.rb:639-640`. GoChat now registers the exact `/twilio/callback` route, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound callbacks by `To` or `MessagingServiceSid`, normalizes phone numbers to stored E.164 channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and durable persister boundary. | `go test ./internal/handler/webhook -run 'TwilioCallback\|TwilioWebhook\|TwilioDeliveryStatus' -count=1`; `go test ./internal/router -run 'RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 892`; tracked route parity is `345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 354`. | Twilio root callback ingress moves to Review for current reference evidence; continue Phase 2/3 callback route audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(twilio): expose voice callbacks` | Advances Phase 2/3 enterprise voice parity by matching Chatwoot enterprise `Twilio::VoiceController#call_twiml/#status/#conference_status/#recording_status` and `routes.rb:643-646`. GoChat now registers `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`; voice callbacks resolve the Twilio phone to a voice-enabled inbox, return conference TwiML for existing call rows, map Twilio call/conference events to persisted call state, and retain status/conference/recording callback payloads in call attributes. | `go test ./internal/router -run 'TwilioVoice\|RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 891`; tracked route parity is `343 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 352`. | Enterprise Twilio voice callbacks move to Review for current reference evidence; continue Phase 2/3 drift audit, callback route audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(custom-domains): expose cloudflare challenge` | Advances Phase 2/3 public custom-domain parity by matching Chatwoot enterprise `CustomDomainsController#verify`, Cloudflare SSL setting keys from `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now registers `/.well-known/cf-custom-hostname-challenge/:id`, resolves the request host against `portals.custom_domain`, returns `Domain not found` for unknown domains, returns `Challenge ID not found` for mismatched `ssl_settings.cf_verification_id`, and serves the stored `cf_verification_body` as plain text on success. | `go test ./internal/router -run 'CustomDomain\|WellKnown\|RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 887`; tracked route parity is `339 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 348`. | Cloudflare custom hostname verification moves to Review for current reference evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
@@ -2355,3 +2356,4 @@ Verification milestone gates:
|
||||
- 2026-06-06: Cloudflare custom-domain checkpoint prepared as `feat(custom-domains): expose cloudflare challenge`; audited Chatwoot enterprise `CustomDomainsController#verify`, `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now exposes `/.well-known/cf-custom-hostname-challenge/:id`, resolves `request.host` against `portals.custom_domain`, checks `ssl_settings.cf_verification_id`, returns Chatwoot plain-text 404 bodies for unknown domains and mismatched challenges, and serves `ssl_settings.cf_verification_body` on success. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
- 2026-06-06: Twilio voice callback checkpoint prepared as `feat(twilio): expose voice callbacks`; audited Chatwoot enterprise `Twilio::VoiceController`, voice callback routes `routes.rb:643-646`, and existing Go contact-call persistence. GoChat now exposes `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`; callbacks resolve `Channel::TwilioSms` phones to voice-enabled inboxes, return conference TwiML for persisted call rows, map Twilio status/conference events to call state, and persist callback payloads/recording metadata. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 callback audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
- 2026-06-06: Twilio root callback checkpoint prepared as `feat(twilio): expose root callback`; audited Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and routes `639-640`. GoChat now exposes `/twilio/callback`, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound events by `To` or `MessagingServiceSid`, normalizes phone numbers to the stored Twilio channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and persister. Focused webhook/router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 callback audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
- 2026-06-06: Root integration callback checkpoint prepared as `feat(integrations): expose oauth callbacks`; audited Chatwoot `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, Shopify account auth, and routes `630/634/654`. GoChat now exposes `/linear/callback`, `/shopify/callback`, and `/notion/callback`, verifies state, exchanges OAuth codes via configurable token endpoints, persists account hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify account auth `redirect_url`. Focused router/service/route-parity tests and route dump/parity regeneration passed; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke.
|
||||
|
||||
@@ -433,6 +433,8 @@ GET /hc/:slug/:locale/search
|
||||
GET /hc/:slug/articles/:article_slug
|
||||
GET /hc/:slug/sitemap.xml
|
||||
GET /health
|
||||
GET /linear/callback
|
||||
GET /notion/callback
|
||||
GET /platform/api/v1/accounts
|
||||
GET /platform/api/v1/accounts/:account_id
|
||||
GET /platform/api/v1/accounts/:account_id/account_users
|
||||
@@ -460,6 +462,7 @@ GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations
|
||||
GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id
|
||||
GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages
|
||||
GET /shopify/callback
|
||||
GET /webhooks/instagram
|
||||
GET /webhooks/tiktok/:business_id
|
||||
GET /webhooks/twitter
|
||||
@@ -890,4 +893,4 @@ PUT /public/api/v1/csat_survey/:id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id
|
||||
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /widget/direct_uploads/:upload_uuid
|
||||
TOTAL: 892
|
||||
TOTAL: 895
|
||||
|
||||
@@ -7,7 +7,7 @@ Generated from:
|
||||
|
||||
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
|
||||
|
||||
Summary: 345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 354 tracked critical routes.
|
||||
Summary: 348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 357 tracked critical routes.
|
||||
|
||||
## Missing Critical Routes
|
||||
|
||||
@@ -227,11 +227,14 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| GET | `/hc/:slug/:locale/search` | `/hc/:slug/:locale/search` | `public/api/v1/portals/search#index` | `routes.rb:593` | exact |
|
||||
| GET | `/hc/:slug/articles/:article_slug` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show` | `routes.rb:601` | exact |
|
||||
| GET | `/hc/:slug/sitemap.xml` | `/hc/:slug/sitemap.xml` | `public/api/v1/portals#sitemap` | `routes.rb:591` | exact |
|
||||
| GET | `/linear/callback` | `/linear/callback` | `linear/callbacks#show` | `routes.rb:630` | exact |
|
||||
| GET | `/notion/callback` | `/notion/callback` | `notion/callbacks#show` | `routes.rb:654` | exact |
|
||||
| GET | `/public/api/v1/csat_survey/:id` | `/public/api/v1/csat_survey/:id` | `public/api/v1/csat_survey#show` | `routes.rb:585` | exact |
|
||||
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `public/api/v1/inboxes/contacts#show` | `routes.rb:572` | exact |
|
||||
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `public/api/v1/inboxes/conversations#index` | `routes.rb:573` | exact |
|
||||
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `public/api/v1/inboxes/conversations#show` | `routes.rb:573` | exact |
|
||||
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `public/api/v1/inboxes/messages#index` | `routes.rb:580` | exact |
|
||||
| GET | `/shopify/callback` | `/shopify/callback` | `shopify/callbacks#show` | `routes.rb:634` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact |
|
||||
|
||||
@@ -36,7 +36,7 @@ func (h *ShopifyIntegrationHandler) Delete(c *gin.Context) {
|
||||
response.OK(c, gin.H{"message": "Shopify integration deleted"})
|
||||
}
|
||||
|
||||
// Auth creates/updates Shopify OAuth credentials.
|
||||
// Auth returns the Shopify OAuth authorize URL.
|
||||
// POST /api/v1/accounts/:account_id/integrations/shopify/auth
|
||||
func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
@@ -51,12 +51,12 @@ func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
hook, svcErr := h.svc.Auth(c.Request.Context(), accountID, req)
|
||||
redirect, svcErr := h.svc.BuildAuthRedirect(c.Request.Context(), accountID, req)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, hook)
|
||||
response.OK(c, redirect)
|
||||
}
|
||||
|
||||
// GetOrders retrieves Shopify orders for an account.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func linearIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("LINEAR_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("linear", accountID)
|
||||
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: envOrDefault("LINEAR_OAUTH_TOKEN_URL", "https://api.linear.app/oauth/token"),
|
||||
ClientID: os.Getenv("LINEAR_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/linear/callback",
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"token_type": body["token_type"],
|
||||
"scope": body["scope"],
|
||||
"refresh_token": body["refresh_token"],
|
||||
}
|
||||
if body["expires_in"] != "" {
|
||||
settings["expires_in"] = body["expires_in"]
|
||||
if seconds, err := strconv.Atoi(body["expires_in"]); err == nil {
|
||||
settings["expires_on"] = time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if err := upsertIntegrationHook(c, db, accountID, model.HookTypeLinear, "", body["access_token"], settings); err != nil {
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func shopifyIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("SHOPIFY_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("shopify", accountID)
|
||||
shop := strings.TrimSpace(c.Query("shop"))
|
||||
if !ok || db == nil || c.Query("code") == "" || shop == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
|
||||
tokenURL := os.Getenv("SHOPIFY_OAUTH_TOKEN_URL")
|
||||
if tokenURL == "" {
|
||||
tokenURL = fmt.Sprintf("https://%s/admin/oauth/access_token", shop)
|
||||
}
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: tokenURL,
|
||||
ClientID: os.Getenv("SHOPIFY_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("SHOPIFY_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/shopify/callback",
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
|
||||
if err := createIntegrationHook(c, db, accountID, model.HookTypeShopify, shop, body["access_token"], map[string]any{"scope": body["scope"]}); err != nil {
|
||||
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func notionIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("NOTION_CLIENT_SECRET"))
|
||||
redirectURL := integrationRedirectURL("notion", accountID)
|
||||
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
|
||||
TokenURL: envOrDefault("NOTION_OAUTH_TOKEN_URL", "https://api.notion.com/v1/oauth/token"),
|
||||
ClientID: os.Getenv("NOTION_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("NOTION_CLIENT_SECRET"),
|
||||
Code: c.Query("code"),
|
||||
RedirectURI: frontendBaseURL() + "/notion/callback",
|
||||
BasicAuth: true,
|
||||
})
|
||||
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"token_type": body["token_type"],
|
||||
"workspace_name": body["workspace_name"],
|
||||
"workspace_id": body["workspace_id"],
|
||||
"workspace_icon": body["workspace_icon"],
|
||||
"bot_id": body["bot_id"],
|
||||
}
|
||||
if owner := strings.TrimSpace(body["owner"]); owner != "" {
|
||||
settings["owner"] = owner
|
||||
}
|
||||
if err := createIntegrationHook(c, db, accountID, model.HookTypeNotion, "", body["access_token"], settings); err != nil {
|
||||
c.Redirect(http.StatusFound, frontendBaseURL())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
type oauthTokenExchangeRequest struct {
|
||||
TokenURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Code string
|
||||
RedirectURI string
|
||||
BasicAuth bool
|
||||
}
|
||||
|
||||
func exchangeOAuthToken(c *gin.Context, req oauthTokenExchangeRequest) (map[string]string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", req.Code)
|
||||
form.Set("redirect_uri", req.RedirectURI)
|
||||
if !req.BasicAuth {
|
||||
form.Set("client_id", req.ClientID)
|
||||
form.Set("client_secret", req.ClientSecret)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, req.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if req.BasicAuth {
|
||||
httpReq.SetBasicAuth(req.ClientID, req.ClientSecret)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("oauth token exchange failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(raw))
|
||||
for key, value := range raw {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
result[key] = typed
|
||||
case float64:
|
||||
result[key] = strconv.FormatInt(int64(typed), 10)
|
||||
case nil:
|
||||
result[key] = ""
|
||||
default:
|
||||
encoded, _ := json.Marshal(typed)
|
||||
result[key] = string(encoded)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func callbackAccountID(state string, secret string) (uint, bool) {
|
||||
state = strings.TrimSpace(state)
|
||||
if state == "" {
|
||||
return 0, false
|
||||
}
|
||||
if id, err := strconv.ParseUint(state, 10, 64); err == nil && id > 0 {
|
||||
return uint(id), true
|
||||
}
|
||||
if secret == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return 0, false
|
||||
}
|
||||
if sub, ok := claims["sub"].(float64); ok && sub > 0 {
|
||||
return uint(sub), true
|
||||
}
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
id, err := strconv.ParseUint(sub, 10, 64)
|
||||
return uint(id), err == nil && id > 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func accountExists(c *gin.Context, db *gorm.DB, accountID uint) bool {
|
||||
var count int64
|
||||
if err := db.WithContext(c.Request.Context()).Model(&model.Account{}).Where("id = ?", accountID).Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func upsertIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
|
||||
settingsJSON, err := json.Marshal(compactMap(settings))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hook model.IntegrationHook
|
||||
err = db.WithContext(c.Request.Context()).
|
||||
Where("account_id = ? AND (app_id = ? OR hook_type = ?)", accountID, string(hookType), hookType).
|
||||
First(&hook).Error
|
||||
if err == nil {
|
||||
hook.AppID = string(hookType)
|
||||
hook.HookType = hookType
|
||||
hook.Status = model.HookStatusActive
|
||||
hook.ReferenceID = referenceID
|
||||
hook.AccessToken = accessToken
|
||||
hook.Settings = datatypes.JSON(settingsJSON)
|
||||
return db.WithContext(c.Request.Context()).Save(&hook).Error
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return createIntegrationHook(c, db, accountID, hookType, referenceID, accessToken, settings)
|
||||
}
|
||||
|
||||
func createIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
|
||||
settingsJSON, err := json.Marshal(compactMap(settings))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hook := &model.IntegrationHook{
|
||||
AccountID: accountID,
|
||||
AppID: string(hookType),
|
||||
HookType: hookType,
|
||||
Status: model.HookStatusActive,
|
||||
AccessToken: accessToken,
|
||||
ReferenceID: referenceID,
|
||||
Settings: datatypes.JSON(settingsJSON),
|
||||
}
|
||||
return db.WithContext(c.Request.Context()).Create(hook).Error
|
||||
}
|
||||
|
||||
func compactMap(values map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if value == nil || value == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func integrationRedirectURL(app string, accountID uint) string {
|
||||
if accountID == 0 {
|
||||
return frontendBaseURL()
|
||||
}
|
||||
return fmt.Sprintf("%s/app/accounts/%d/settings/integrations/%s", frontendBaseURL(), accountID, app)
|
||||
}
|
||||
|
||||
func redirectWithError(location string) string {
|
||||
if strings.Contains(location, "?") {
|
||||
return location + "&error=true"
|
||||
}
|
||||
return location + "?error=true"
|
||||
}
|
||||
|
||||
func frontendBaseURL() string {
|
||||
return strings.TrimRight(envOrDefault("FRONTEND_URL", "http://localhost:3000"), "/")
|
||||
}
|
||||
|
||||
func envOrDefault(key string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -207,6 +207,12 @@ func RegisterRoutes(
|
||||
engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation)
|
||||
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
|
||||
|
||||
// Root OAuth callbacks used by Chatwoot integration app redirects.
|
||||
// Reference: Chatwoot routes.rb linear/shopify/notion callback routes.
|
||||
engine.GET("/linear/callback", linearIntegrationCallback(db))
|
||||
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
|
||||
engine.GET("/notion/callback", notionIntegrationCallback(db))
|
||||
|
||||
// Dashboard shell routes used by Chatwoot mailer and push deep links.
|
||||
// Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index.
|
||||
engine.GET("/app", dashboardIndex)
|
||||
|
||||
@@ -2,17 +2,20 @@ package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -45,6 +48,9 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
"GET /.well-known/apple-app-site-association",
|
||||
"GET /.well-known/microsoft-identity-association.json",
|
||||
"GET /.well-known/cf-custom-hostname-challenge/:id",
|
||||
"GET /linear/callback",
|
||||
"GET /shopify/callback",
|
||||
"GET /notion/callback",
|
||||
"GET /app",
|
||||
"GET /app/*params",
|
||||
"GET /api/v1/accounts/:account_id/captain/assistants/tools",
|
||||
@@ -206,6 +212,106 @@ func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCallbacksCreateHooksAndRedirect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
t.Setenv("LINEAR_CLIENT_ID", "linear-client")
|
||||
t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret")
|
||||
t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client")
|
||||
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
|
||||
t.Setenv("NOTION_CLIENT_ID", "notion-client")
|
||||
t.Setenv("NOTION_CLIENT_SECRET", "notion-secret")
|
||||
|
||||
t.Setenv("LINEAR_OAUTH_TOKEN_URL", "https://oauth.example.test/linear")
|
||||
t.Setenv("SHOPIFY_OAUTH_TOKEN_URL", "https://oauth.example.test/shopify")
|
||||
t.Setenv("NOTION_OAUTH_TOKEN_URL", "https://oauth.example.test/notion")
|
||||
withFakeOAuthTransport(t, map[string]map[string]any{
|
||||
"/linear": {"access_token": "linear-token", "refresh_token": "linear-refresh", "token_type": "bearer", "expires_in": 3600, "scope": "read,write"},
|
||||
"/shopify": {"access_token": "shopify-token", "scope": "read_orders"},
|
||||
"/notion": {"access_token": "notion-token", "token_type": "bearer", "workspace_name": "Docs", "workspace_id": "workspace-1", "bot_id": "bot-1"},
|
||||
})
|
||||
|
||||
db, account := setupRouterIntegrationCallbackDB(t)
|
||||
engine := gin.New()
|
||||
engine.GET("/linear/callback", linearIntegrationCallback(db))
|
||||
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
|
||||
engine.GET("/notion/callback", notionIntegrationCallback(db))
|
||||
|
||||
linearState := signedCallbackState(t, account.ID, "linear-secret")
|
||||
linear := performGet(engine, "/linear/callback?code=linear-code&state="+url.QueryEscape(linearState))
|
||||
if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/linear" {
|
||||
t.Fatalf("expected linear integration redirect, got %d %q", linear.Code, linear.Header().Get("Location"))
|
||||
}
|
||||
|
||||
shopifyState := signedCallbackState(t, account.ID, "shopify-secret")
|
||||
shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state="+url.QueryEscape(shopifyState))
|
||||
if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/shopify" {
|
||||
t.Fatalf("expected shopify integration redirect, got %d %q", shopify.Code, shopify.Header().Get("Location"))
|
||||
}
|
||||
|
||||
notionState := signedCallbackState(t, account.ID, "notion-secret")
|
||||
notion := performGet(engine, "/notion/callback?code=notion-code&state="+url.QueryEscape(notionState))
|
||||
if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/notion" {
|
||||
t.Fatalf("expected notion integration redirect, got %d %q", notion.Code, notion.Header().Get("Location"))
|
||||
}
|
||||
|
||||
var hooks []model.IntegrationHook
|
||||
if err := db.Order("app_id ASC").Find(&hooks).Error; err != nil {
|
||||
t.Fatalf("failed to load hooks: %v", err)
|
||||
}
|
||||
if len(hooks) != 3 {
|
||||
t.Fatalf("expected three integration hooks, got %d", len(hooks))
|
||||
}
|
||||
|
||||
seen := map[string]model.IntegrationHook{}
|
||||
for _, hook := range hooks {
|
||||
seen[hook.AppID] = hook
|
||||
}
|
||||
if seen["linear"].AccessToken != "linear-token" || seen["linear"].Status != model.HookStatusActive {
|
||||
t.Fatalf("expected active linear hook with token, got %+v", seen["linear"])
|
||||
}
|
||||
if seen["shopify"].AccessToken != "shopify-token" || seen["shopify"].ReferenceID != "store.myshopify.com" {
|
||||
t.Fatalf("expected shopify hook with reference shop, got %+v", seen["shopify"])
|
||||
}
|
||||
if seen["notion"].AccessToken != "notion-token" || !strings.Contains(string(seen["notion"].Settings), "workspace_name") {
|
||||
t.Fatalf("expected notion hook with workspace settings, got %+v settings=%s", seen["notion"], string(seen["notion"].Settings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCallbacksRedirectSafelyOnInvalidState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret")
|
||||
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
|
||||
db, _ := setupRouterIntegrationCallbackDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/linear/callback", linearIntegrationCallback(db))
|
||||
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
|
||||
engine.GET("/notion/callback", notionIntegrationCallback(db))
|
||||
|
||||
linear := performGet(engine, "/linear/callback?code=linear-code&state=bad-state")
|
||||
if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test" {
|
||||
t.Fatalf("expected linear safe redirect, got %d %q", linear.Code, linear.Header().Get("Location"))
|
||||
}
|
||||
shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state=bad-state")
|
||||
if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test?error=true" {
|
||||
t.Fatalf("expected shopify error redirect, got %d %q", shopify.Code, shopify.Header().Get("Location"))
|
||||
}
|
||||
notion := performGet(engine, "/notion/callback?code=notion-code&state=bad-state")
|
||||
if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test" {
|
||||
t.Fatalf("expected notion safe redirect, got %d %q", notion.Code, notion.Header().Get("Location"))
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.IntegrationHook{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("failed to count hooks: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected invalid callbacks not to create hooks, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat")
|
||||
@@ -331,6 +437,74 @@ func setupRouterPortalDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func setupRouterIntegrationCallbackDB(t *testing.T) (*gorm.DB, *model.Account) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Account{}, &model.IntegrationHook{}); err != nil {
|
||||
t.Fatalf("failed to migrate integration callback models: %v", err)
|
||||
}
|
||||
account := &model.Account{Name: "Integration Account", Locale: "en"}
|
||||
if err := db.Create(account).Error; err != nil {
|
||||
t.Fatalf("failed to create account: %v", err)
|
||||
}
|
||||
return db, account
|
||||
}
|
||||
|
||||
func signedCallbackState(t *testing.T, accountID uint, secret string) string {
|
||||
t.Helper()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": accountID,
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign callback state: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func withFakeOAuthTransport(t *testing.T, payloads map[string]map[string]any) {
|
||||
t.Helper()
|
||||
original := http.DefaultClient
|
||||
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("expected token exchange POST, got %s", req.Method)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.Fatalf("failed to parse token exchange form: %v", err)
|
||||
}
|
||||
if req.Form.Get("code") == "" || req.Form.Get("redirect_uri") == "" {
|
||||
t.Fatalf("expected code and redirect_uri in token exchange form: %v", req.Form)
|
||||
}
|
||||
payload, ok := payloads[req.URL.Path]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected OAuth token URL path: %s", req.URL.Path)
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode fake OAuth payload: %v", err)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(string(encoded))),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
t.Cleanup(func() { http.DefaultClient = original })
|
||||
}
|
||||
|
||||
func setupRouterTwilioVoiceDB(t *testing.T) (*gorm.DB, *model.Call) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
||||
|
||||
@@ -4,10 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ShopifyIntegrationService implements Shopify integration business logic.
|
||||
@@ -28,6 +33,10 @@ type CreateShopifyAuthRequest struct {
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
}
|
||||
|
||||
type ShopifyAuthResponse struct {
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
}
|
||||
|
||||
// Delete removes a Shopify integration hook for an account.
|
||||
func (s *ShopifyIntegrationService) Delete(ctx context.Context, accountID uint) error {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeShopify)
|
||||
@@ -93,6 +102,40 @@ func (s *ShopifyIntegrationService) Auth(ctx context.Context, accountID uint, re
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
// BuildAuthRedirect returns the Chatwoot-compatible Shopify OAuth authorize URL.
|
||||
// Reference: Api::V1::Accounts::Integrations::ShopifyController#auth.
|
||||
func (s *ShopifyIntegrationService) BuildAuthRedirect(_ context.Context, accountID uint, req CreateShopifyAuthRequest) (*ShopifyAuthResponse, error) {
|
||||
shopDomain := strings.TrimSpace(req.ShopDomain)
|
||||
if shopDomain == "" {
|
||||
return nil, fmt.Errorf("Shop domain is required")
|
||||
}
|
||||
clientID := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_ID"))
|
||||
clientSecret := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_SECRET"))
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil, fmt.Errorf("Shopify OAuth is not configured")
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": accountID,
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
state, err := token.SignedString([]byte(clientSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate Shopify state: %w", err)
|
||||
}
|
||||
|
||||
frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:3000"
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("client_id", clientID)
|
||||
params.Set("scope", "read_customers,read_orders,read_fulfillments")
|
||||
params.Set("redirect_uri", frontendURL+"/shopify/callback")
|
||||
params.Set("state", state)
|
||||
return &ShopifyAuthResponse{RedirectURL: fmt.Sprintf("https://%s/admin/oauth/authorize?%s", shopDomain, params.Encode())}, nil
|
||||
}
|
||||
|
||||
// GetOrders retrieves Shopify orders for an account (proxy to Shopify API).
|
||||
// GET /api/v1/accounts/:account_id/integrations/shopify/orders
|
||||
func (s *ShopifyIntegrationService) GetOrders(ctx context.Context, accountID uint) ([]map[string]interface{}, error) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -87,6 +89,27 @@ func TestShopifyIntegrationService_Auth_CreateNew(t *testing.T) {
|
||||
assert.Len(t, hooks, 1, "one Shopify hook should exist for the account")
|
||||
}
|
||||
|
||||
func TestShopifyIntegrationService_BuildAuthRedirect_ChatwootPayload(t *testing.T) {
|
||||
svc, db := setupShopifyIntegrationService(t)
|
||||
accountID := seedShopifyAccount(db, t)
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
||||
t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client")
|
||||
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
|
||||
|
||||
result, err := svc.BuildAuthRedirect(context.Background(), accountID, CreateShopifyAuthRequest{ShopDomain: "my-store.myshopify.com"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, strings.HasPrefix(result.RedirectURL, "https://my-store.myshopify.com/admin/oauth/authorize?"))
|
||||
|
||||
parsed, err := url.Parse(result.RedirectURL)
|
||||
require.NoError(t, err)
|
||||
query := parsed.Query()
|
||||
assert.Equal(t, "shopify-client", query.Get("client_id"))
|
||||
assert.Equal(t, "read_customers,read_orders,read_fulfillments", query.Get("scope"))
|
||||
assert.Equal(t, "https://app.example.test/shopify/callback", query.Get("redirect_uri"))
|
||||
assert.NotEmpty(t, query.Get("state"))
|
||||
}
|
||||
|
||||
func TestShopifyIntegrationService_Auth_UpdateExisting(t *testing.T) {
|
||||
svc, db := setupShopifyIntegrationService(t)
|
||||
accountID := seedShopifyAccount(db, t)
|
||||
@@ -167,4 +190,4 @@ func TestShopifyIntegrationService_Delete_NotFound(t *testing.T) {
|
||||
err := svc.Delete(ctx, accountID)
|
||||
assert.Error(t, err, "Delete should fail when no Shopify integration exists")
|
||||
assert.Contains(t, err.Error(), "not found", "error should indicate integration not found")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user