diff --git a/backend/pkg/httpserver/delete_subscription.go b/backend/pkg/httpserver/delete_subscription.go new file mode 100644 index 000000000..162db3373 --- /dev/null +++ b/backend/pkg/httpserver/delete_subscription.go @@ -0,0 +1,69 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by a applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpserver + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" + "github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend" +) + +// nolint:ireturn, revive // Expected ireturn for openapi generation. +func (s *Server) DeleteSubscription( + ctx context.Context, + request backend.DeleteSubscriptionRequestObject, +) (backend.DeleteSubscriptionResponseObject, error) { + userCheck := CheckAuthenticatedUser[backend.DeleteSubscriptionResponseObject](ctx, "DeleteSubscription", + func(code int, message string) backend.DeleteSubscriptionResponseObject { + return backend.DeleteSubscription500JSONResponse(backend.BasicErrorModel{Code: code, Message: message}) + }) + if userCheck.User == nil { + return userCheck.Response, nil + } + + err := s.wptMetricsStorer.DeleteSavedSearchSubscription(ctx, userCheck.User.ID, request.SubscriptionId) + if err != nil { + if errors.Is(err, backendtypes.ErrEntityDoesNotExist) { + return backend.DeleteSubscription404JSONResponse( + backend.BasicErrorModel{ + Code: http.StatusNotFound, + Message: "subscription not found", + }, + ), nil + } else if errors.Is(err, backendtypes.ErrUserNotAuthorizedForAction) { + return backend.DeleteSubscription403JSONResponse( + backend.BasicErrorModel{ + Code: http.StatusForbidden, + Message: "user not authorized to delete this subscription", + }, + ), nil + } + + slog.ErrorContext(ctx, "unable to delete this subscription", "err", err) + + return backend.DeleteSubscription500JSONResponse( + backend.BasicErrorModel{ + Code: http.StatusInternalServerError, + Message: "could not delete subscription", + }, + ), nil + } + + return backend.DeleteSubscription204Response{}, nil +} diff --git a/backend/pkg/httpserver/delete_subscription_test.go b/backend/pkg/httpserver/delete_subscription_test.go new file mode 100644 index 000000000..2e3028a76 --- /dev/null +++ b/backend/pkg/httpserver/delete_subscription_test.go @@ -0,0 +1,130 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/GoogleChrome/webstatus.dev/lib/auth" + "github.com/GoogleChrome/webstatus.dev/lib/backendtypes" +) + +func TestDeleteSubscription(t *testing.T) { + testUser := &auth.User{ + ID: "test-user", + GitHubUserID: nil, + } + + testCases := []struct { + name string + cfg *MockDeleteSavedSearchSubscriptionConfig + expectedCallCount int + authMiddlewareOption testServerOption + request *http.Request + expectedResponse *http.Response + }{ + { + name: "success", + cfg: &MockDeleteSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + err: nil, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodDelete, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: createEmptyBodyResponse(http.StatusNoContent), + }, + { + name: "not found", + cfg: &MockDeleteSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + err: backendtypes.ErrEntityDoesNotExist, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodDelete, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: testJSONResponse(http.StatusNotFound, `{"code":404,"message":"subscription not found"}`), + }, + { + name: "internal error", + cfg: &MockDeleteSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + err: errTest, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodDelete, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: testJSONResponse(http.StatusInternalServerError, + `{"code":500,"message":"could not delete subscription"}`), + }, + { + name: "forbidden - user cannot access subscription", + cfg: &MockDeleteSavedSearchSubscriptionConfig{ + expectedUserID: "test-user", + expectedSubscriptionID: "sub-id", + err: backendtypes.ErrUserNotAuthorizedForAction, + }, + expectedCallCount: 1, + authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)), + request: httptest.NewRequest( + http.MethodDelete, + "/v1/users/me/subscriptions/sub-id", + nil, + ), + expectedResponse: testJSONResponse(http.StatusForbidden, + `{"code":403,"message":"user not authorized to delete this subscription"}`), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + //nolint:exhaustruct + mockStorer := &MockWPTMetricsStorer{ + deleteSavedSearchSubscriptionCfg: tc.cfg, + t: t, + } + myServer := Server{ + wptMetricsStorer: mockStorer, + metadataStorer: nil, + userGitHubClientFactory: nil, + operationResponseCaches: nil, + baseURL: getTestBaseURL(t), + } + assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption) + assertMocksExpectations(t, + tc.expectedCallCount, + mockStorer.callCountDeleteSavedSearchSubscription, + "DeleteSavedSearchSubscription", + nil) + }) + } +} diff --git a/backend/pkg/httpserver/server_test.go b/backend/pkg/httpserver/server_test.go index e43ed61cf..447d37b67 100644 --- a/backend/pkg/httpserver/server_test.go +++ b/backend/pkg/httpserver/server_test.go @@ -1235,6 +1235,16 @@ func (m *mockServerInterface) ListMissingOneImplementationFeatures(ctx context.C panic("unimplemented") } +// DeleteSubscription implements backend.StrictServerInterface. +// nolint: ireturn // WONTFIX - generated method signature +func (m *mockServerInterface) DeleteSubscription(ctx context.Context, + _ backend.DeleteSubscriptionRequestObject) ( + backend.DeleteSubscriptionResponseObject, error) { + assertUserInCtx(ctx, m.t, m.expectedUserInCtx) + m.callCount++ + panic("unimplemented") +} + func (m *mockServerInterface) assertCallCount(expectedCallCount int) { if m.callCount != expectedCallCount { m.t.Errorf("expected mock server to be used %d times. only used %d times", expectedCallCount, m.callCount) diff --git a/openapi/backend/openapi.yaml b/openapi/backend/openapi.yaml index 2b051be05..a7d703838 100644 --- a/openapi/backend/openapi.yaml +++ b/openapi/backend/openapi.yaml @@ -1078,7 +1078,39 @@ paths: type: string # GET operation to retrieve a specific subscription (to be added in a future PR) # PATCH operation to update a specific subscription (to be added in a future PR) - # DELETE operation to delete a specific subscription (to be added in a future PR) + # DELETE operation to delete a specific subscription + delete: + summary: Delete a subscription for a saved search + operationId: deleteSubscription + security: + - bearerAuth: [] + responses: + '204': + description: No Content + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' + '500': + description: Internal Service Error + content: + application/json: + schema: + $ref: '#/components/schemas/BasicErrorModel' components: parameters: browserPathParam: