diff --git a/Makefile b/Makefile index e06ccce..9a30175 100644 --- a/Makefile +++ b/Makefile @@ -183,6 +183,7 @@ test-http-client: /workdir/get_speaker_auth.http \ /workdir/get_blacklist.http \ /workdir/post_alexa_certificate.http \ + /workdir/unsupported_routes.http \ /workdir/spotify_full_flow.http \ /workdir/customer_support.http \ /workdir/power_on.http \ diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 5353e36..f86e553 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1225,6 +1225,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember) }) + // Speakers POST to /group/ (with trailing slash) when forwarding + // the addGroup payload to Marge during stereo-pair formation -- + // see issue #252. Register both forms so chi accepts either. // Speakers POST to /group/ (with trailing slash) when forwarding // the addGroup payload to Marge during stereo-pair formation -- // see issue #252. Register both forms so chi accepts either. @@ -1266,31 +1269,38 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions) }) + // The /accounts/* group mirrored /streaming/account/* for compatibility, but + // no speaker or app was ever observed using this prefix in the recording + // corpus (the integration tests that exercised it were migrated onto the + // /streaming equivalents). The whole mirror is therefore treated as unused + // and stubbed (HandleUnsupported): it logs + 501s so any real-world use + // surfaces instead of being silently dropped, leaving the prefix a clean + // removal candidate for the #451 refactor. r.Route("/accounts", func(r chi.Router) { r.Route("/{account}", func(r chi.Router) { - r.Get("/full", server.HandleMargeAccountFull) - r.Get("/sources", server.HandleMargeAccountSources) - r.Get("/devices", server.HandleMargeAccountDevices) + r.Get("/full", server.HandleUnsupported) + r.Get("/sources", server.HandleUnsupported) + r.Get("/devices", server.HandleUnsupported) - r.Post("/devices", server.HandleMargeAddDevice) + r.Post("/devices", server.HandleUnsupported) - r.Delete("/devices/{device}", server.HandleMargeRemoveDevice) - r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup) - r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup) - r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer) - r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember) + r.Delete("/devices/{device}", server.HandleUnsupported) + r.Get("/devices/{device}/group", server.HandleUnsupported) + r.Get("/devices/{device}/group/", server.HandleUnsupported) + r.Get("/devices/{device}/group/server", server.HandleUnsupported) + r.Get("/devices/{device}/group/member", server.HandleUnsupported) - r.Post("/group", server.HandleMargeAddGroup) - r.Post("/group/", server.HandleMargeAddGroup) - r.Post("/group/{groupId}", server.HandleMargeModifyGroup) - r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup) - r.Delete("/group", server.HandleMargeDeleteAccountGroups) - r.Delete("/group/", server.HandleMargeDeleteAccountGroups) - r.Get("/devices/{device}/presets", server.HandleMargePresets) - r.Get("/devices/{device}/recents", server.HandleMargeRecents) + r.Post("/group", server.HandleUnsupported) + r.Post("/group/", server.HandleUnsupported) + r.Post("/group/{groupId}", server.HandleUnsupported) + r.Delete("/group/{groupId}", server.HandleUnsupported) + r.Delete("/group", server.HandleUnsupported) + r.Delete("/group/", server.HandleUnsupported) + r.Get("/devices/{device}/presets", server.HandleUnsupported) + r.Get("/devices/{device}/recents", server.HandleUnsupported) - r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset) - r.Post("/devices/{device}/recents", server.HandleMargeAddRecent) + r.Post("/devices/{device}/presets/{presetNumber}", server.HandleUnsupported) + r.Post("/devices/{device}/recents", server.HandleUnsupported) }) }) diff --git a/cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt b/cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt index dc0b423..9832690 100644 --- a/cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt +++ b/cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt @@ -1,16 +1,6 @@ -DELETE /accounts/{account}/group -DELETE /accounts/{account}/group/ -DELETE /accounts/{account}/group/{groupId} DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* DELETE /streaming/account/{account}/group -GET /accounts/{account}/devices -GET /accounts/{account}/devices/{device}/group -GET /accounts/{account}/devices/{device}/group/ -GET /accounts/{account}/devices/{device}/group/member -GET /accounts/{account}/devices/{device}/group/server -GET /accounts/{account}/devices/{device}/recents -GET /accounts/{account}/full GET /bmx-icons/* GET /bmx/tunein/v1/navigate GET /bmx/tunein/v1/navigate/* @@ -27,10 +17,6 @@ GET /streaming/account/{account}/device/{device}/group/server GET /streaming/account/{account}/device/{device}/recent GET /streaming/account/{account}/presets GET /streaming/device_setting/account/{account}/device/{device}/device_settings -POST /accounts/{account}/devices/{device}/recents -POST /accounts/{account}/group -POST /accounts/{account}/group/ -POST /accounts/{account}/group/{groupId} POST /core02/svc-bmx-adapter-orion/prod/orion/token POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index e15265f..0addba7 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -1,9 +1,9 @@ CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm -DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm -DELETE /accounts/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm -DELETE /accounts/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm -DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm +DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleUnsupported-fm +DELETE /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm +DELETE /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm +DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm @@ -18,15 +18,15 @@ DELETE /streaming/account/{account}/group handlers.( DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm GET / handlers.(*Server).HandleRoot-fm -GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm -GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm -GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm -GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm -GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm -GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm -GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm -GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm -GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm +GET /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm +GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm GET /bmx-icons/* handlers.(*Server).HandleBmxIcons GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm @@ -109,12 +109,12 @@ OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm -POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm -POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm -POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm -POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm -POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm -POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm +POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm +POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleUnsupported-fm +POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm +POST /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm +POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm +POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm diff --git a/pkg/service/handlers/handlers_unsupported.go b/pkg/service/handlers/handlers_unsupported.go new file mode 100644 index 0000000..c7091e2 --- /dev/null +++ b/pkg/service/handlers/handlers_unsupported.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "io" + "log" + "net/http" +) + +// unsupportedReportURL is where we ask operators to report a real client that +// depends on an endpoint we treat as unused. +const unsupportedReportURL = "https://github.com/gesellix/Bose-SoundTouch/issues" + +// maxUnsupportedBodyLog caps how much of an unexpected request body we log. +const maxUnsupportedBodyLog = 2048 + +// HandleUnsupported is wired to frozen routes that no real speaker or app was +// ever observed using (verified against the recorded interaction corpus). It +// exists so the API surface the refactor (issue #451) has to preserve stays as +// small as possible: these routes are candidates for removal, but rather than +// drop them blind we make them fail loudly and observably. +// +// It returns 501 Not Implemented and logs the full request (method, path, +// query, client IP, user-agent and a capped, sanitized body) plus a message +// asking the operator to report it, so that if some device or old app actually +// relies on the route we find out and restore it instead of silently breaking +// it during the refactor. +func (s *Server) HandleUnsupported(w http.ResponseWriter, r *http.Request) { + client := clientHostFromRemoteAddr(r.RemoteAddr) + + var body []byte + if r.Body != nil { + body, _ = io.ReadAll(io.LimitReader(r.Body, maxUnsupportedBodyLog)) + } + + log.Printf("[unsupported] %s %s%s called by client=%s ua=%q — this endpoint is treated as unused and returns 501. "+ + "If a speaker or app you rely on needs it, please report it at %s so we can keep it. body=%q", + sanitizeLog(r.Method), + sanitizeLog(r.URL.Path), + sanitizeLog(querySuffix(r)), + sanitizeLog(client), + sanitizeLog(r.UserAgent()), + unsupportedReportURL, + sanitizeLog(string(body)), + ) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write([]byte(`{"error":"not_implemented","message":"This endpoint is not implemented by AfterTouch. ` + + `If your speaker or app depends on it, please report it at ` + unsupportedReportURL + `"}`)) +} + +// querySuffix returns "?" or "" so the log line shows the query +// without an empty trailing "?". +func querySuffix(r *http.Request) string { + if r.URL.RawQuery == "" { + return "" + } + + return "?" + r.URL.RawQuery +} diff --git a/tests/integration/http-client/register_device.http b/tests/integration/http-client/register_device.http index 95fb58e..757347f 100644 --- a/tests/integration/http-client/register_device.http +++ b/tests/integration/http-client/register_device.http @@ -1,5 +1,5 @@ -### POST /{{accountId}}/devices (Register Device) -POST {{host}}/accounts/{{accountId}}/devices +### POST /streaming/account/{{accountId}}/device/ (Register Device) +POST {{host}}/streaming/account/{{accountId}}/device/ Content-Type: application/vnd.bose.streaming-v1.2+xml Authorization: Bearer {{token}} diff --git a/tests/integration/http-client/spotify_full_flow.http b/tests/integration/http-client/spotify_full_flow.http index 17ba65b..9473d28 100644 --- a/tests/integration/http-client/spotify_full_flow.http +++ b/tests/integration/http-client/spotify_full_flow.http @@ -40,7 +40,7 @@ Authorization: Basic admin change_me! # 4. Verify Marge Source Registration and extract Spotify Source ID # @name Get Marge Sources -GET {{host}}/accounts/{{accountId}}/sources +GET {{host}}/streaming/account/{{accountId}}/sources > {% client.test("Spotify source registered in Marge", function() { @@ -94,7 +94,7 @@ GET {{host}}/accounts/{{accountId}}/sources # 5. Add a Spotify Preset # @name Update Preset -POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets/1 +PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}/preset/1 Content-Type: application/xml @@ -114,7 +114,7 @@ Content-Type: application/xml # 6. Verify Preset in Marge # @name Get Presets -GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets +GET {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}/presets > {% client.test("Verify preset content", function() { diff --git a/tests/integration/http-client/unregister_device.http b/tests/integration/http-client/unregister_device.http index 35d741c..dbb1ee9 100644 --- a/tests/integration/http-client/unregister_device.http +++ b/tests/integration/http-client/unregister_device.http @@ -1,5 +1,5 @@ -### DELETE /{{accountId}}/devices/{{deviceId}} (Unregister Device) -DELETE {{host}}/accounts/{{accountId}}/devices/{{deviceId}} +### DELETE /streaming/account/{{accountId}}/device/{{deviceId}} (Unregister Device) +DELETE {{host}}/streaming/account/{{accountId}}/device/{{deviceId}} Authorization: Bearer {{token}} > {% @@ -7,13 +7,3 @@ Authorization: Bearer {{token}} client.assert(response.status === 200, "Response status is not 200"); }); %} - -### DELETE /streaming/account/{{accountId}}/device/{{deviceId}} (Unregister Device Variant) -DELETE {{host}}/streaming/account/{{accountId}}/device/{{deviceId}} -Authorization: Bearer {{token}} - -> {% - client.test("Device unregistered successfully (variant)", function() { - client.assert(response.status === 200, "Response status is not 200"); - }); -%} diff --git a/tests/integration/http-client/unsupported_routes.http b/tests/integration/http-client/unsupported_routes.http new file mode 100644 index 0000000..52d07f0 --- /dev/null +++ b/tests/integration/http-client/unsupported_routes.http @@ -0,0 +1,147 @@ +### Unsupported (zero-usage) /accounts/* mirror -> 501 +### +### The /accounts/* group mirrored /streaming/account/* for compatibility, but no +### speaker or app was ever observed using this prefix across the full recording +### corpus, and every operation it offers is provably served via the +### /streaming/account/* paths that real clients DO use (the integration tests +### were migrated onto those). The whole mirror is therefore wired to +### HandleUnsupported, which logs the full request + client IP + a "please report +### this" message and returns 501, so any real-world use surfaces instead of +### being silently broken and the prefix becomes a clean removal candidate for +### the #451 refactor. These tests pin that 501 contract. +### +### NOTE: the real /streaming/account/* (and /streaming/...) routes are NOT +### stubbed — those are the frozen Bose contract and stay on their real handlers +### even where our corpus didn't exercise them. + +### GET /accounts/{a}/full +GET {{host}}/accounts/{{accountId}}/full +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/sources (real clients use /streaming/account/{a}/sources) +GET {{host}}/accounts/{{accountId}}/sources +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/devices +GET {{host}}/accounts/{{accountId}}/devices +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### POST /accounts/{a}/devices (real clients use POST /streaming/account/{a}/device/) +POST {{host}}/accounts/{{accountId}}/devices +User-Agent: Bose_Lisa/27.0.6 +Content-Type: application/xml + + + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### DELETE /accounts/{a}/devices/{deviceId} (real clients use DELETE /streaming/account/{a}/device/{d}) +DELETE {{host}}/accounts/{{accountId}}/devices/{{deviceId}} +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/devices/{deviceId}/group +GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/devices/{deviceId}/group/ +GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group/ +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/devices/{deviceId}/group/server +GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group/server +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/devices/{deviceId}/group/member +GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/group/member +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/devices/{deviceId}/presets (real clients use /streaming/account/{a}/device/{d}/presets) +GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### GET /accounts/{a}/devices/{deviceId}/recents +GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/recents +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### POST /accounts/{a}/devices/{deviceId}/recents +POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/recents +User-Agent: Bose_Lisa/27.0.6 +Content-Type: application/xml + + + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### POST /accounts/{a}/devices/{deviceId}/presets/{n} (real clients use PUT /streaming/account/{a}/device/{d}/preset/{n}) +POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets/1 +User-Agent: Bose_Lisa/27.0.6 +Content-Type: application/xml + + + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### POST /accounts/{a}/group +POST {{host}}/accounts/{{accountId}}/group +User-Agent: Bose_Lisa/27.0.6 +Content-Type: application/xml + + + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### POST /accounts/{a}/group/ +POST {{host}}/accounts/{{accountId}}/group/ +User-Agent: Bose_Lisa/27.0.6 +Content-Type: application/xml + + + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### POST /accounts/{a}/group/{groupId} +POST {{host}}/accounts/{{accountId}}/group/1 +User-Agent: Bose_Lisa/27.0.6 +Content-Type: application/xml + + + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### DELETE /accounts/{a}/group +DELETE {{host}}/accounts/{{accountId}}/group +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### DELETE /accounts/{a}/group/ +DELETE {{host}}/accounts/{{accountId}}/group/ +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %} + +### DELETE /accounts/{a}/group/{groupId} +DELETE {{host}}/accounts/{{accountId}}/group/1 +User-Agent: Bose_Lisa/27.0.6 + +> {% client.test("501", function () { client.assert(response.status === 501, "got " + response.status); }); %}