diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..836aaab --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,169 @@ +name: Deploy Dev + +on: + workflow_dispatch: + push: + branches: + - dev + +permissions: + contents: read + packages: write + +concurrency: + group: deploy-development + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: devkor-github/ontime-back + IMAGE_TAG: dev-${{ github.sha }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ./ontime-back + file: ./ontime-back/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy-to-remote-pc: + needs: build-and-push + runs-on: ubuntu-latest + environment: development + env: + DEV_DEPLOY_DIR: ${{ secrets.DEV_DEPLOY_DIR || format('/home/{0}/OnTime-back-dev', secrets.DEV_REMOTE_USER) }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Prepare dev deploy directory + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEV_REMOTE_HOST }} + username: ${{ secrets.DEV_REMOTE_USER }} + key: ${{ secrets.DEV_REMOTE_SSH_KEY }} + script: | + set -eu + mkdir -p "${{ env.DEV_DEPLOY_DIR }}" + + - name: Upload compose files to remote PC + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEV_REMOTE_HOST }} + username: ${{ secrets.DEV_REMOTE_USER }} + key: ${{ secrets.DEV_REMOTE_SSH_KEY }} + source: "ontime-back/docker-compose.yml,ontime-back/docker-compose.dev.yml" + target: ${{ env.DEV_DEPLOY_DIR }} + strip_components: 1 + + - name: Pull image and restart dev containers + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEV_REMOTE_HOST }} + username: ${{ secrets.DEV_REMOTE_USER }} + key: ${{ secrets.DEV_REMOTE_SSH_KEY }} + script: | + set -eu + + DEPLOY_DIR="${{ env.DEV_DEPLOY_DIR }}" + CONTAINER_NAME="ontime-dev-container" + + mkdir -p "$DEPLOY_DIR" + cd "$DEPLOY_DIR" + + umask 077 + cat > .env <<'EOF' + IMAGE_TAG=${{ env.IMAGE_TAG }} + BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + BACKEND_CONTAINER_NAME=ontime-dev-container + BACKEND_HTTP_PORT=${{ secrets.DEV_BACKEND_HTTP_PORT || '8081' }} + BACKEND_MEMORY_LIMIT=${{ secrets.DEV_BACKEND_MEMORY_LIMIT || '768m' }} + BACKEND_CPU_LIMIT=${{ secrets.DEV_BACKEND_CPU_LIMIT || '1.0' }} + SERVER_PORT=8080 + SPRING_PROFILES_ACTIVE=dev + JAVA_TOOL_OPTIONS=-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom + + MYSQL_DATABASE=${{ secrets.DEV_MYSQL_DATABASE || 'ontime_dev' }} + MYSQL_USER=${{ secrets.DEV_MYSQL_USER || 'ontime_dev' }} + MYSQL_PASSWORD=${{ secrets.DEV_MYSQL_PASSWORD || 'ontime_dev_password' }} + MYSQL_ROOT_PASSWORD=${{ secrets.DEV_MYSQL_ROOT_PASSWORD || 'ontime_dev_root_password' }} + + SPRING_APPLICATION_NAME=${{ secrets.DEV_SPRING_APPLICATION_NAME || 'ontime-back-dev' }} + SPRING_DATASOURCE_URL=${{ secrets.DEV_SPRING_DATASOURCE_URL || 'jdbc:mysql://mysql:3306/ontime_dev?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true' }} + SPRING_DATASOURCE_USERNAME=${{ secrets.DEV_SPRING_DATASOURCE_USERNAME || secrets.DEV_MYSQL_USER || 'ontime_dev' }} + SPRING_DATASOURCE_PASSWORD=${{ secrets.DEV_SPRING_DATASOURCE_PASSWORD || secrets.DEV_MYSQL_PASSWORD || 'ontime_dev_password' }} + SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver + SPRING_JPA_HIBERNATE_DDL_AUTO=validate + + SPRING_FLYWAY_ENABLED=true + SPRING_FLYWAY_BASELINE_ON_MIGRATE=false + + JWT_SECRET_KEY=${{ secrets.DEV_JWT_SECRETKEY || 'dev_secret_key_for_ontime_back_remote_pc_development_1234567890' }} + JWT_ACCESS_EXPIRATION=${{ secrets.DEV_JWT_ACCESS_EXPIRATION || '3600000' }} + JWT_REFRESH_EXPIRATION=${{ secrets.DEV_JWT_REFRESH_EXPIRATION || '1209600000' }} + JWT_ACCESS_HEADER=${{ secrets.DEV_JWT_ACCESS_HEADER || 'Authorization' }} + JWT_REFRESH_HEADER=${{ secrets.DEV_JWT_REFRESH_HEADER || 'Authorization-refresh' }} + + GOOGLE_WEB_CLIENT_ID=${{ secrets.DEV_GOOGLE_WEB_CLIENT_ID || 'dev-google-web-client-id' }} + GOOGLE_APP_CLIENT_ID=${{ secrets.DEV_GOOGLE_APP_CLIENT_ID || 'dev-google-app-client-id' }} + + APPLE_CLIENT_ID=${{ secrets.DEV_APPLE_CLIENT_ID || 'dev-apple-client-id' }} + APPLE_TEAM_ID=${{ secrets.DEV_APPLE_TEAM_ID || 'dev-apple-team-id' }} + APPLE_LOGIN_KEY=${{ secrets.DEV_APPLE_LOGIN_KEY || 'dev-apple-key-id' }} + APPLE_PRIVATE_KEY_BASE64=${{ secrets.DEV_APPLE_PRIVATE_KEY_BASE64 }} + FEATURE_APPLE_LOGIN_ENABLED=${{ secrets.DEV_FEATURE_APPLE_LOGIN_ENABLED || 'false' }} + + FIREBASE_CREDENTIALS_BASE64=${{ secrets.DEV_FIREBASE_CREDENTIALS_BASE64 }} + EOF + + echo "${{ secrets.GHCR_READ_TOKEN }}" | sudo docker login ghcr.io -u "${{ secrets.GHCR_USERNAME }}" --password-stdin + + if sudo docker compose version >/dev/null 2>&1; then + COMPOSE="sudo docker compose" + else + COMPOSE="sudo docker-compose" + fi + + $COMPOSE -f docker-compose.yml -f docker-compose.dev.yml pull + $COMPOSE -f docker-compose.yml -f docker-compose.dev.yml up -d --remove-orphans + + HEALTHY=false + for attempt in $(seq 1 30); do + STATUS="$(sudo docker inspect -f '{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)" + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy." + HEALTHY=true + break + fi + echo "Waiting for healthy container status; current status: ${STATUS:-unknown}" + sleep 5 + done + + if [ "$HEALTHY" != "true" ]; then + sudo docker logs --tail=200 "$CONTAINER_NAME" || true + exit 1 + fi + + curl -fsS "http://127.0.0.1:${{ secrets.DEV_BACKEND_HTTP_PORT || '8081' }}/actuator/health/readiness" diff --git a/docs/account-deletion-api.md b/docs/account-deletion-api.md index deabccb..e9d1763 100644 --- a/docs/account-deletion-api.md +++ b/docs/account-deletion-api.md @@ -6,7 +6,9 @@ Frontend integration guide for deleting an OnTime account with optional withdraw Account deletion hard-deletes the user from OnTime. The request can optionally include feedback. If feedback is provided, the backend stores it separately from the `User` table so it remains available after the account is deleted. -For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. +For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account. + +For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`. ## Authentication @@ -99,14 +101,19 @@ Content-Type: application/json Success response: -```text -구글 로그인 회원탈퇴 성공 +```json +{ + "status": "success", + "code": "200", + "message": "구글 로그인 회원탈퇴 성공", + "data": null +} ``` Behavior: - Revokes the stored Google OAuth token for OnTime. -- Deletes the local OnTime account. +- Deletes the local OnTime account even if Google token revocation fails. - Saves optional feedback if `message` is nonblank. - Does not delete the user's actual Google account. @@ -118,7 +125,7 @@ Expected frontend result after successful revoke: Important caveat: - The Google unlink only works if the backend has a valid Google refresh/access token saved in `socialLoginToken`. -- If the client never provided a real Google refresh token, Google revoke may fail and the endpoint may return an error before local deletion. +- If the client never provided a real Google refresh token, Google revoke may fail. The backend logs the revoke failure and still deletes the local OnTime account. ## Apple Account Deletion @@ -143,14 +150,19 @@ Content-Type: application/json Success response: -```text -애플 로그인 회원탈퇴 성공 +```json +{ + "status": "success", + "code": "200", + "message": "애플 로그인 회원탈퇴 성공", + "data": null +} ``` Behavior: - Revokes the stored Apple OAuth token for OnTime. -- Deletes the local OnTime account. +- Deletes the local OnTime account even if Apple token revocation fails. - Saves optional feedback if `message` is nonblank. - Does not delete the user's Apple ID. @@ -177,4 +189,4 @@ Feedback is not linked by foreign key to the deleted user. - Use `/oauth2/apple/me` for Apple-linked accounts if the product requirement is to unlink Apple access. - Use `/users/me/delete` for normal local deletion. - After a successful deletion response, clear local auth state and navigate to the logged-out screen. -- Do not retry automatically on social revoke errors without showing the user, because the local account may not have been deleted. +- If social provider revocation fails, the backend still returns success after deleting the local account. Follow up through support or backend logs if provider unlink confirmation is required. diff --git a/docs/account-deletion-request-page.md b/docs/account-deletion-request-page.md new file mode 100644 index 0000000..3c6781b --- /dev/null +++ b/docs/account-deletion-request-page.md @@ -0,0 +1,42 @@ +# Account Deletion Request Page + +Issue: #440 + +## Public URL + +Use the production backend HTTPS host plus the stable path: + +```text +https://3.38.172.54.nip.io/account-deletion +``` + +If the production backend is later moved to a custom domain, keep the same path +and update Google Play Console to the custom-domain URL. + +## Hosting Surface + +The page is hosted by the Spring Boot backend at `GET /account-deletion`. + +It is public and does not require an OnTime access token. The path is explicitly +allowed in both Spring Security route authorization and the custom JWT filter +skip list. + +## Request Handling Channel + +Outside-app account deletion requests are handled by email: + +```text +jjoonleo@gmail.com +``` + +The authenticated backend API remains available for in-app account deletion. + +## Retention Enforcement + +Backend-owned database retention is enforced for: + +- Optional account deletion feedback: up to 1 year. +- Backend API logs: up to 90 days. + +Backup rotation and external monitoring/security log retention must be enforced +by the production infrastructure and logging tools, not only by application code. diff --git a/docs/account-deletion-verification-evidence.md b/docs/account-deletion-verification-evidence.md new file mode 100644 index 0000000..ed2a298 --- /dev/null +++ b/docs/account-deletion-verification-evidence.md @@ -0,0 +1,83 @@ +# Account Deletion Verification Evidence + +Issue: #439 +Date: 2026-05-10 +Status: implementation verified locally; release environment evidence pending + +## Scope + +This document records backend verification evidence for account deletion. It is +intended to support frontend release QA and privacy review, not to replace +release-environment database, log, analytics, monitoring, or backup checks. + +## Verification Run + +Command executed from `ontime-back`: + +```bash +./gradlew test --rerun-tasks +``` + +Result: passed. + +Relevant test coverage: + +- `devkor.ontime_back.service.UserAuthServiceTest` +- `devkor.ontime_back.controller.UserAuthControllerTest` +- `devkor.ontime_back.controller.SocialAuthControllerTest` + +## Provider Evidence Matrix + +| Provider | Endpoint Called | Environment | Test Account ID | Request Time | Response | Re-login Fails? | Owner | Evidence Link | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Normal | `DELETE /users/me/delete` | Local test | Test fixture | 2026-05-10 | JSON success, message `계정이 성공적으로 삭제되었습니다!` | Not verified locally | Backend repo | `UserAuthControllerTest`, `UserAuthServiceTest` | +| Google | `DELETE /oauth2/google/me` | Local test | Test fixture | 2026-05-10 | JSON success, message `구글 로그인 회원탈퇴 성공` | Not verified locally | Backend repo | `SocialAuthControllerTest`, `UserAuthServiceTest` | +| Apple | `DELETE /oauth2/apple/me` | Local test | Test fixture | 2026-05-10 | JSON success, message `애플 로그인 회원탈퇴 성공` | Not verified locally | Backend repo | `SocialAuthControllerTest`, `UserAuthServiceTest` | + +Release QA must replace local-test evidence with staging or +production-equivalent evidence that includes the real environment, account ID, +request timestamp, response, owner, and evidence link. + +## Data Deletion And Retention Matrix + +| Data Category | Backend Location | Deleted, Anonymized, Retained, or N/A | Retention Duration | Retention Reason | Verification Method | Owner | +| --- | --- | --- | --- | --- | --- | --- | +| User profile fields such as email, name, image, note, role, punctuality fields, and social identity | `user` table | Deleted in local integration test | N/A after deletion | Account deletion hard-delete | `UserAuthService.deleteUser` calls `userRepository.delete(user)`; test asserts deleted user row is absent | Backend repo; release env owner TBD | +| Password or auth credentials for normal accounts | `user.password` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete | User row deletion removes password field | Backend repo; release env owner TBD | +| OAuth provider linkage for Google | `user.social_type`, `user.social_id`, `user.social_login_token`; Google revoke endpoint | Local linkage deleted; provider revoke attempted | Local linkage N/A after deletion; provider-side retention TBD | Local account deletion hard-delete; provider unlink depends on Google revoke result | Controller test verifies deletion continues when Google revoke throws | Backend repo; provider/env owner TBD | +| OAuth provider linkage for Apple | `user.social_type`, `user.social_id`, `user.social_login_token`; Apple revoke endpoint | Local linkage deleted; provider revoke attempted | Local linkage N/A after deletion; provider-side retention TBD | Local account deletion hard-delete; provider unlink depends on Apple revoke result | Controller test verifies deletion continues when Apple revoke throws | Backend repo; provider/env owner TBD | +| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD | +| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD | +| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD | +| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD | +| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD | +| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD | +| Spare time setting | `user.spare_time`, `user_setting` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and setting cascade | Test asserts user setting row is absent | Backend repo; release env owner TBD | +| General feedback sent through `/feedback` | `feedback` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts feedback rows are absent | Backend repo; release env owner TBD | +| Account deletion feedback sent in delete request body | `account_deletion_feedback` | Retained with anonymized email hash in local integration test, then scheduled for cleanup | Up to 1 year | Service quality review and deletion-related support issues | `saveAccountDeletionFeedback` stores deleted user ID, social type, SHA-256 normalized email hash, message, and created timestamp without a user foreign key; `RetentionCleanupService` deletes rows older than 1 year; tests assert retained row has no plaintext email and cleanup honors the cutoff | Backend repo | +| Backend API request logs | `api_log` | Retained as operational metadata, then scheduled for cleanup | Up to 90 days | Service operation and debugging | `LoggingAspect` stores route, method, actor, client IP, status, latency, and timestamp without request bodies; `RetentionCleanupService` deletes rows older than 90 days; tests assert cleanup honors the cutoff | Backend repo | +| Hosting logs, audit logs, crash logs, analytics, monitoring events, or security records | Hosting/logging/monitoring/analytics/security tools | TBD | TBD | TBD | Requires release environment/tooling review | Backend/environment owner | +| Backups or disaster recovery snapshots | Database backups/snapshots | TBD | TBD | TBD | Requires backup policy review | Backend/environment owner | + +## Social Token Revocation Decision + +Google and Apple deletion endpoints attempt provider token revocation before +local account deletion. If the provider revoke call throws, the controller logs a +warning and still calls `UserAuthService.deleteUser`. + +This means a successful social deletion response verifies local OnTime account +deletion, but it does not prove provider-side unlink or revoke success. Provider +unlink must be checked separately with real Google and Apple test accounts in +the release environment. + +## Remaining Release/Privacy Checks + +- Run deletion in the release verification environment for normal, Google, and + Apple accounts. +- Attach database/API evidence for every provider row in the matrix. +- Confirm provider-side unlink/re-login behavior for Google and Apple. +- Confirm operational log, crash log, analytics, monitoring, and backup + retention behavior. +- Confirm hosting/application log, monitoring, analytics, audit, and security + event retention behavior outside the backend database. +- Compare the final evidence with the privacy policy draft before closing #439. diff --git a/docs/deployment.md b/docs/deployment.md index a34dcf2..396ed38 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,8 +1,10 @@ -# Production Deployment +# Deployment -This service deploys as an immutable Docker image published to GitHub Container Registry (GHCR). Runtime configuration is injected through the EC2 `.env` file generated by GitHub Actions; private resource files are not copied into the image or bind-mounted from the host. +This service deploys as an immutable Docker image published to GitHub Container Registry (GHCR). Runtime configuration is injected through `.env` files generated by GitHub Actions; private resource files are not copied into the image or bind-mounted from the host. -## Required GitHub Secrets +Production deploys to EC2 from `main`. Development deploys to a remote Ubuntu/Linux PC from `dev`. + +## Production GitHub Secrets Deployment access: @@ -85,9 +87,7 @@ base64 -i ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 | tr -d '\n' Push to the `main` branch, or run `.github/workflows/deploy.yml` manually, to deploy production. -Pushes to `dev` run CI only. There is no dev-server deploy workflow in the one-EC2 plan. - -The workflow: +The production workflow: 1. Builds `ontime-back/Dockerfile` from the `ontime-back/` context. 2. Pushes two GHCR tags: @@ -99,6 +99,65 @@ The workflow: 6. Runs `docker compose pull && docker compose up -d --remove-orphans`. 7. Waits until the `ontime-container` Docker health status is `healthy`. +## Development Remote PC Deployment + +Push to the `dev` branch, or run `.github/workflows/deploy-dev.yml` manually, to deploy the development backend to the remote PC. + +The development workflow: + +1. Builds `ontime-back/Dockerfile` from the `ontime-back/` context. +2. Pushes two GHCR tags: + - `ghcr.io/devkor-github/ontime-back:dev-` + - `ghcr.io/devkor-github/ontime-back:dev-latest` +3. Uploads `docker-compose.yml` and `docker-compose.dev.yml` to the remote PC. +4. Writes a development `.env` from GitHub secrets and safe dev defaults. +5. Runs `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --remove-orphans`. +6. Starts MySQL as a private Docker Compose service with persistent volume `ontime-dev-mysql-data`. +7. Waits until the `ontime-dev-container` Docker health status is `healthy`. + +Required development secrets: + +- `DEV_REMOTE_HOST` +- `DEV_REMOTE_USER` +- `DEV_REMOTE_SSH_KEY` +- `GHCR_USERNAME` +- `GHCR_READ_TOKEN` + +Optional development secrets: + +- `DEV_DEPLOY_DIR` (defaults to `/home//OnTime-back-dev`) +- `DEV_BACKEND_HTTP_PORT` (defaults to `8081`) +- `DEV_BACKEND_MEMORY_LIMIT` (defaults to `768m`) +- `DEV_BACKEND_CPU_LIMIT` (defaults to `1.0`) +- `DEV_MYSQL_DATABASE` (defaults to `ontime_dev`) +- `DEV_MYSQL_USER` (defaults to `ontime_dev`) +- `DEV_MYSQL_PASSWORD` (defaults to `ontime_dev_password`) +- `DEV_MYSQL_ROOT_PASSWORD` (defaults to `ontime_dev_root_password`) +- `DEV_SPRING_APPLICATION_NAME` (defaults to `ontime-back-dev`) +- `DEV_SPRING_DATASOURCE_URL` (defaults to the Compose MySQL service) +- `DEV_SPRING_DATASOURCE_USERNAME` (defaults to the dev MySQL user) +- `DEV_SPRING_DATASOURCE_PASSWORD` (defaults to the dev MySQL password) +- `DEV_JWT_SECRETKEY` +- `DEV_JWT_ACCESS_EXPIRATION` +- `DEV_JWT_REFRESH_EXPIRATION` +- `DEV_JWT_ACCESS_HEADER` +- `DEV_JWT_REFRESH_HEADER` +- `DEV_GOOGLE_WEB_CLIENT_ID` +- `DEV_GOOGLE_APP_CLIENT_ID` +- `DEV_APPLE_CLIENT_ID` +- `DEV_APPLE_TEAM_ID` +- `DEV_APPLE_LOGIN_KEY` +- `DEV_APPLE_PRIVATE_KEY_BASE64` +- `DEV_FEATURE_APPLE_LOGIN_ENABLED` (defaults to `false`) +- `DEV_FIREBASE_CREDENTIALS_BASE64` + +Remote PC prerequisites: + +- Ubuntu/Linux host with normal SSH access from GitHub Actions. +- Docker and the Docker Compose plugin installed. +- Inbound firewall access for the backend HTTP port, default `8081`. +- No public inbound MySQL port is required; MySQL stays inside the Docker network. + ## Health Verification The production image exposes a Docker healthcheck against: @@ -117,6 +176,16 @@ curl -fsS http://localhost:8080/actuator/health/readiness nc -zv ontime-prod.cpoeguokwaq5.ap-northeast-2.rds.amazonaws.com 3306 ``` +Manual checks on the remote development PC: + +```bash +cd /home//OnTime-back-dev +sudo docker compose -f docker-compose.yml -f docker-compose.dev.yml ps +sudo docker inspect -f '{{.State.Health.Status}}' ontime-dev-container +sudo docker logs --tail=200 ontime-dev-container +curl -fsS http://:8081/actuator/health/readiness +``` + ## Rollback Every deploy is tagged by commit SHA. To roll back, set `IMAGE_TAG` in `/home/ubuntu/OnTime-back/.env` to the previous known-good SHA, then restart from the existing Compose file: diff --git a/docs/git-workflow.md b/docs/git-workflow.md index 1a0c6d4..fc7d6f6 100644 --- a/docs/git-workflow.md +++ b/docs/git-workflow.md @@ -1,6 +1,6 @@ -# Git Workflow And Production Deployment Strategy +# Git Workflow And Deployment Strategy -This document describes the recommended Git strategy for OnTime-back with one production EC2 server and one private production RDS instance. +This document describes the recommended Git strategy for OnTime-back with one production EC2 server, one private production RDS instance, and one remote-PC development server. ## Goals @@ -8,7 +8,7 @@ This document describes the recommended Git strategy for OnTime-back with one pr - Make every server deployment traceable to a Git branch and commit. - Avoid using deployment branches as places where product code diverges. - Keep feature branches short-lived and easy to review. -- Keep `dev` as an integration branch only; it must not deploy a long-running dev backend. +- Use `dev` as the integration branch and remote-PC development deploy source. ## Branch Model @@ -25,7 +25,7 @@ Branch responsibilities: | Branch | Purpose | Deployment | | --- | --- | --- | | `main` | Production-ready code and source of truth | Production server | -| `dev` | Integrated code for QA, frontend/mobile testing, and pre-release validation | No direct deployment | +| `dev` | Integrated code for QA, frontend/mobile testing, and pre-release validation | Remote PC development server | | `feature/*` | New feature work | No direct deployment | | `fix/*` | Bug fixes | No direct deployment | | `chore/*` | Maintenance, docs, config, CI changes | No direct deployment | @@ -59,7 +59,7 @@ feature/* -> dev 5. After review, merge into `dev`. -6. Validate the integrated code without running a long-lived dev backend on EC2. +6. Validate the integrated code on the remote PC development backend. 7. When the release candidate is ready, open a pull request from `dev` into `main`. @@ -84,6 +84,7 @@ Use branch-based CI and production deployment: ```text pull_request to dev/main -> test workflow +push to dev -> development environment -> remote PC development server push to main -> production environment -> production server ``` @@ -91,6 +92,7 @@ Recommended GitHub environments: | Environment | Source Branch | Server | Approval | | --- | --- | --- | --- | +| `development` | `dev` | Remote PC development server | Optional | | `production` | `main` | Production server | Manual approval recommended | ## CI/CD Workflow @@ -99,6 +101,7 @@ Recommended simple setup: ```text .github/workflows/test.yml +.github/workflows/deploy-dev.yml .github/workflows/deploy.yml ``` @@ -107,9 +110,9 @@ Expected triggers: ```text pull_request to dev -> run tests pull_request to main -> run tests -push to dev -> no deployment +push to dev -> deploy to remote PC development server push to main -> deploy to production server -workflow_dispatch -> allow manual redeploy or rollback support +workflow_dispatch -> allow manual redeploy for each deploy workflow ``` Production deploy should use production secrets only: @@ -123,7 +126,17 @@ SPRING_DATASOURCE_USERNAME SPRING_DATASOURCE_PASSWORD ``` -Do not add `DEV_*` deployment secrets or a dev-server workflow unless the infrastructure plan changes deliberately. +Development deploy should use development secrets only: + +```text +DEV_REMOTE_HOST +DEV_REMOTE_USER +DEV_REMOTE_SSH_KEY +GHCR_USERNAME +GHCR_READ_TOKEN +``` + +Optional `DEV_*` secrets can override the default dev deploy directory, HTTP port, MySQL credentials, and non-production OAuth/Firebase settings. ## Branch Protection @@ -171,7 +184,7 @@ The current repository has `main` and `deploy` as separate long-lived branches. ```text deploy branch -> retired main branch -> production -dev branch -> integration and CI only +dev branch -> integration and remote PC development deployment ``` Recommended migration sequence: @@ -189,19 +202,20 @@ git push origin dev ``` 5. Change production deployment to trigger from `main`. -6. Ensure there is no workflow that deploys from `dev`. +6. Ensure `dev` deploys only to the remote PC development environment. 7. Update GitHub production environment secrets. -8. Protect `main` and `dev`. -9. Stop using `deploy` for new work. -10. Delete or archive stale merged feature branches after confirming they are no longer needed. +8. Update GitHub development environment secrets for the remote PC. +9. Protect `main` and `dev`. +10. Stop using `deploy` for new work. +11. Delete or archive stale merged feature branches after confirming they are no longer needed. ## Practical Rules - New work branches from `dev`. - Normal PR target is `dev`. - Release PR target is `main`. +- `dev` deploys only to the remote PC development server. - Production deploys only from `main`. -- `dev` runs CI only and does not deploy. - Do not commit directly to `main`. - Do not commit directly to `dev` unless it is an emergency coordination fix. - Delete feature branches after merge. diff --git a/ontime-back/docker-compose.dev.yml b/ontime-back/docker-compose.dev.yml new file mode 100644 index 0000000..1d2bb07 --- /dev/null +++ b/ontime-back/docker-compose.dev.yml @@ -0,0 +1,37 @@ +services: + backend: + container_name: "${BACKEND_CONTAINER_NAME:-ontime-dev-container}" + depends_on: + mysql: + condition: service_healthy + + mysql: + image: mysql:8.0 + container_name: ontime-dev-mysql + environment: + MYSQL_DATABASE: "${MYSQL_DATABASE:-ontime_dev}" + MYSQL_USER: "${MYSQL_USER:-ontime_dev}" + MYSQL_PASSWORD: "${MYSQL_PASSWORD:-ontime_dev_password}" + MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-ontime_dev_root_password}" + TZ: Asia/Seoul + volumes: + - ontime-dev-mysql-data:/var/lib/mysql + restart: unless-stopped + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-time-zone=+09:00 + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root --password=$$MYSQL_ROOT_PASSWORD"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + ontime-dev-mysql-data: diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java index 06ac177..e061a36 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java @@ -72,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .headers(headers -> headers .frameOptions(frameOptions -> frameOptions.disable())) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll() + .requestMatchers("/", "/account-deletion", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll() .requestMatchers("/health", "/actuator/health/**", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "/swagger-ui.html").permitAll() .requestMatchers("/error").permitAll() diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/AccountDeletionPageController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/AccountDeletionPageController.java new file mode 100644 index 0000000..2d62db9 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/AccountDeletionPageController.java @@ -0,0 +1,145 @@ +package devkor.ontime_back.controller; + +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.concurrent.TimeUnit; + +@RestController +public class AccountDeletionPageController { + + @GetMapping(value = "/account-deletion", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity getAccountDeletionPage() { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_HTML) + .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) + .body(""" + + + + + + OnTime Account Deletion Request + + + +
+
+

OnTime Account Deletion Request

+

App name: OnTime

+

Developer: ejun

+

Contact email: jjoonleo@gmail.com

+
+ +
+

Request Account Deletion Outside The App

+

+ You can request deletion of your OnTime account without installing or opening the app by emailing + jjoonleo@gmail.com. + Please use the email address associated with your OnTime account and include the subject + "OnTime account deletion request". +

+

+ OnTime may ask for additional information only as needed to verify that the request is from the + account owner. You do not need to log in to this website to submit the request by email. +

+ +

Data Deleted

+

+ When a user deletes their OnTime account, OnTime deletes the local account and associated app data, + including schedules, preparation data, notification schedules, user settings, alarm settings, + alarm status, device records, FCM tokens, and session tokens. +

+ +

Data Retained

+

+ If the user submits optional account deletion feedback, OnTime may retain that feedback for up to + 1 year to review service quality and deletion-related support issues. This feedback is stored + separately from the deleted account and uses a hashed email value instead of the plaintext email address. +

+

+ Operational logs, monitoring records, and security records may be retained for up to 90 days for + service operation, debugging, security, and abuse-prevention purposes, unless a longer period is + required for legal compliance or an active security investigation. +

+

+ Backup copies containing deleted account data are removed according to the normal backup rotation + and are retained for no longer than 30 days, unless a longer period is required by law or security investigation. +

+ +

Privacy Policy

+

+ Once the public OnTime privacy policy is hosted, it should be linked from this page and listed in + Google Play Console together with this account deletion request URL. +

+
+
+ + + """); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java index fedf244..380db47 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java @@ -32,7 +32,7 @@ @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { - private static final List NO_CHECK_URLS = List.of("/login", "/health", "/actuator/health", "/swagger-ui", "/sign-up", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login"); + private static final List NO_CHECK_URLS = List.of("/login", "/health", "/actuator/health", "/swagger-ui", "/sign-up", "/account-deletion", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login"); private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java index 4683bb9..d039938 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java @@ -4,8 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.UUID; @Repository public interface AccountDeletionFeedbackRepository extends JpaRepository { + + long deleteByCreatedAtBefore(LocalDateTime cutoff); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/ApiLogRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/ApiLogRepository.java index 03b2edf..9b4bd4a 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/ApiLogRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/ApiLogRepository.java @@ -3,6 +3,9 @@ import devkor.ontime_back.entity.ApiLog; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; public interface ApiLogRepository extends JpaRepository { + + long deleteByCreatedAtBefore(LocalDateTime cutoff); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/RetentionCleanupService.java b/ontime-back/src/main/java/devkor/ontime_back/service/RetentionCleanupService.java new file mode 100644 index 0000000..bbc3520 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/service/RetentionCleanupService.java @@ -0,0 +1,45 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; +import devkor.ontime_back.repository.ApiLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RetentionCleanupService { + + private static final int ACCOUNT_DELETION_FEEDBACK_RETENTION_YEARS = 1; + private static final int API_LOG_RETENTION_DAYS = 90; + + private final AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; + private final ApiLogRepository apiLogRepository; + + @Scheduled(cron = "0 30 3 * * *") + public void cleanupExpiredRetentionData() { + cleanupExpiredRetentionData(LocalDateTime.now()); + } + + @Transactional + public RetentionCleanupResult cleanupExpiredRetentionData(LocalDateTime now) { + LocalDateTime accountDeletionFeedbackCutoff = now.minusYears(ACCOUNT_DELETION_FEEDBACK_RETENTION_YEARS); + LocalDateTime apiLogCutoff = now.minusDays(API_LOG_RETENTION_DAYS); + + long deletedAccountDeletionFeedback = accountDeletionFeedbackRepository.deleteByCreatedAtBefore(accountDeletionFeedbackCutoff); + long deletedApiLogs = apiLogRepository.deleteByCreatedAtBefore(apiLogCutoff); + + log.info("Retention cleanup completed. deletedAccountDeletionFeedback: {}, deletedApiLogs: {}", + deletedAccountDeletionFeedback, deletedApiLogs); + + return new RetentionCleanupResult(deletedAccountDeletionFeedback, deletedApiLogs); + } + + public record RetentionCleanupResult(long deletedAccountDeletionFeedback, long deletedApiLogs) { + } +} diff --git a/ontime-back/src/main/resources/application-dev.properties b/ontime-back/src/main/resources/application-dev.properties new file mode 100644 index 0000000..953a8bd --- /dev/null +++ b/ontime-back/src/main/resources/application-dev.properties @@ -0,0 +1,53 @@ +# Database Configuration +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:mysql://mysql:3306/ontime_dev?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ontime_dev} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:ontime_dev_password} +spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} + +# JPA / Hibernate +spring.jpa.database=mysql +spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect +spring.jpa.hibernate.ddl-auto=${SPRING_JPA_HIBERNATE_DDL_AUTO:validate} +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# Flyway +spring.flyway.enabled=${SPRING_FLYWAY_ENABLED:true} +spring.flyway.baseline-on-migrate=${SPRING_FLYWAY_BASELINE_ON_MIGRATE:false} + +# JWT Configuration +jwt.secret.key=${JWT_SECRET_KEY:dev_secret_key_for_ontime_back_remote_pc_development_1234567890} +jwt.access.expiration=${JWT_ACCESS_EXPIRATION:3600000} +jwt.refresh.expiration=${JWT_REFRESH_EXPIRATION:1209600000} +jwt.access.header=${JWT_ACCESS_HEADER:Authorization} +jwt.refresh.header=${JWT_REFRESH_HEADER:Authorization-refresh} + +# Google OAuth +google.web.client-id=${GOOGLE_WEB_CLIENT_ID:dev-google-web-client-id} +google.app.client-id=${GOOGLE_APP_CLIENT_ID:dev-google-app-client-id} + +# Apple OAuth +apple.client.id=${APPLE_CLIENT_ID:dev-apple-client-id} +apple.team.id=${APPLE_TEAM_ID:dev-apple-team-id} +apple.login.key=${APPLE_LOGIN_KEY:dev-apple-key-id} +apple.client.secret=${APPLE_CLIENT_SECRET:} +apple.private-key.base64=${APPLE_PRIVATE_KEY_BASE64:} + +# Firebase +firebase.credentials.base64=${FIREBASE_CREDENTIALS_BASE64:} + +# Logging +logging.level.root=INFO +logging.level.devkor.ontime_back=DEBUG + +# Feature flags +feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:false} + +# Actuator +management.endpoint.health.probes.enabled=true +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=always +management.health.readinessstate.enabled=true +management.health.livenessstate.enabled=true +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=30s diff --git a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java index a438193..cc11859 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java +++ b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java @@ -25,7 +25,8 @@ FeedbackController.class, FirebaseTokenController.class, AlarmController.class, - SocialAuthController.class + SocialAuthController.class, + AccountDeletionPageController.class } ) public abstract class ControllerTestSupport { diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/AccountDeletionPageControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/AccountDeletionPageControllerTest.java new file mode 100644 index 0000000..e4a2cac --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/AccountDeletionPageControllerTest.java @@ -0,0 +1,36 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.ControllerTestSupport; +import devkor.ontime_back.TestSecurityConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestSecurityConfig.class) +class AccountDeletionPageControllerTest extends ControllerTestSupport { + + @DisplayName("계정 삭제 요청 페이지를 로그인 없이 HTML로 조회한다.") + @Test + void getAccountDeletionPage() throws Exception { + mockMvc.perform(get("/account-deletion")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", containsString("max-age=3600"))) + .andExpect(content().contentTypeCompatibleWith("text/html")) + .andExpect(content().string(containsString("OnTime Account Deletion Request"))) + .andExpect(content().string(containsString("App name: OnTime"))) + .andExpect(content().string(containsString("Developer: ejun"))) + .andExpect(content().string(containsString("jjoonleo@gmail.com"))) + .andExpect(content().string(containsString("without installing or opening the app"))) + .andExpect(content().string(containsString("schedules, preparation data, notification schedules"))) + .andExpect(content().string(containsString("retain that feedback for up to"))) + .andExpect(content().string(containsString("Operational logs, monitoring records, and security records may be retained for up to 90 days"))) + .andExpect(content().string(containsString("retained for no longer than 30 days"))) + .andExpect(content().string(containsString("privacy policy"))); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/SocialAuthControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/SocialAuthControllerTest.java new file mode 100644 index 0000000..5cc4e27 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/SocialAuthControllerTest.java @@ -0,0 +1,72 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.ControllerTestSupport; +import devkor.ontime_back.TestSecurityConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestSecurityConfig.class) +class SocialAuthControllerTest extends ControllerTestSupport { + + @DisplayName("구글 토큰 철회가 실패해도 계정 삭제를 계속 진행한다") + @Test + void googleDeleteContinuesWhenTokenRevocationFails() throws Exception { + // given + Long userId = 1L; + when(userAuthService.getUserIdFromToken(any())).thenReturn(userId); + doThrow(new RuntimeException("revocation failed")) + .when(googleLoginService) + .revokeToken(userId); + when(userAuthService.deleteUser(eq(userId), isNull())).thenReturn(userId); + + // when // then + mockMvc.perform(delete("/oauth2/google/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.message").value("구글 로그인 회원탈퇴 성공")); + + verify(googleLoginService).revokeToken(userId); + verify(userAuthService).deleteUser(eq(userId), isNull()); + } + + @DisplayName("애플 토큰 철회가 실패해도 계정 삭제를 계속 진행한다") + @Test + void appleDeleteContinuesWhenTokenRevocationFails() throws Exception { + // given + Long userId = 1L; + when(userAuthService.getUserIdFromToken(any())).thenReturn(userId); + doThrow(new RuntimeException("revocation failed")) + .when(appleLoginService) + .revokeToken(userId); + when(userAuthService.deleteUser(eq(userId), isNull())).thenReturn(userId); + + // when // then + mockMvc.perform(delete("/oauth2/apple/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.message").value("애플 로그인 회원탈퇴 성공")); + + verify(appleLoginService).revokeToken(userId); + verify(userAuthService).deleteUser(eq(userId), isNull()); + } + + @DisplayName("카카오 전용 계정 삭제 엔드포인트는 제공하지 않는다") + @Test + void kakaoProviderSpecificDeleteEndpointIsNotMapped() throws Exception { + mockMvc.perform(delete("/oauth2/kakao/me")) + .andExpect(status().isNotFound()); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..100028b --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java @@ -0,0 +1,32 @@ +package devkor.ontime_back.global.jwt; + +import devkor.ontime_back.repository.UserRepository; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class JwtAuthenticationFilterTest { + + @DisplayName("계정 삭제 요청 페이지는 액세스 토큰 없이 JWT 필터를 통과한다.") + @Test + void skipsAccountDeletionPage() throws Exception { + JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + UserRepository userRepository = mock(UserRepository.class); + FilterChain filterChain = mock(FilterChain.class); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/account-deletion"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtTokenProvider, never()).extractAccessToken(request); + verify(jwtTokenProvider, never()).extractRefreshToken(request); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/RetentionCleanupServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/RetentionCleanupServiceTest.java new file mode 100644 index 0000000..b922e50 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/RetentionCleanupServiceTest.java @@ -0,0 +1,135 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.entity.AccountDeletionFeedback; +import devkor.ontime_back.entity.ApiLog; +import devkor.ontime_back.entity.SocialType; +import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; +import devkor.ontime_back.repository.ApiLogRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class RetentionCleanupServiceTest { + + @Autowired + private RetentionCleanupService retentionCleanupService; + + @Autowired + private AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; + + @Autowired + private ApiLogRepository apiLogRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @AfterEach + void tearDown() { + accountDeletionFeedbackRepository.deleteAllInBatch(); + apiLogRepository.deleteAllInBatch(); + } + + @DisplayName("탈퇴 피드백은 1년보다 오래된 행만 삭제한다") + @Test + void cleanupDeletesOnlyAccountDeletionFeedbackOlderThanOneYear() { + // given + LocalDateTime now = LocalDateTime.of(2026, 5, 10, 12, 0); + UUID expiredFeedbackId = UUID.randomUUID(); + UUID cutoffFeedbackId = UUID.randomUUID(); + UUID retainedFeedbackId = UUID.randomUUID(); + + accountDeletionFeedbackRepository.save(AccountDeletionFeedback.builder() + .feedbackId(expiredFeedbackId) + .deletedUserId(1L) + .socialType(SocialType.GOOGLE) + .emailHash("a".repeat(64)) + .message("expired feedback") + .createdAt(now.minusYears(1).minusSeconds(1)) + .build()); + accountDeletionFeedbackRepository.save(AccountDeletionFeedback.builder() + .feedbackId(cutoffFeedbackId) + .deletedUserId(2L) + .socialType(SocialType.APPLE) + .emailHash("b".repeat(64)) + .message("cutoff feedback") + .createdAt(now.minusYears(1)) + .build()); + accountDeletionFeedbackRepository.save(AccountDeletionFeedback.builder() + .feedbackId(retainedFeedbackId) + .deletedUserId(3L) + .socialType(SocialType.KAKAO) + .emailHash("c".repeat(64)) + .message("retained feedback") + .createdAt(now.minusYears(1).plusSeconds(1)) + .build()); + + // when + RetentionCleanupService.RetentionCleanupResult result = retentionCleanupService.cleanupExpiredRetentionData(now); + + // then + assertThat(result.deletedAccountDeletionFeedback()).isEqualTo(1); + assertThat(accountDeletionFeedbackRepository.findById(expiredFeedbackId)).isEmpty(); + assertThat(accountDeletionFeedbackRepository.findById(cutoffFeedbackId)).isPresent(); + assertThat(accountDeletionFeedbackRepository.findById(retainedFeedbackId)).isPresent(); + } + + @DisplayName("API 로그는 90일보다 오래된 행만 삭제한다") + @Test + void cleanupDeletesOnlyApiLogsOlderThanNinetyDays() { + // given + LocalDateTime now = LocalDateTime.of(2026, 5, 10, 12, 0); + ApiLog expiredLog = apiLogRepository.save(ApiLog.builder() + .requestUrl("/expired") + .requestMethod("GET") + .userId("1") + .clientIp("127.0.0.1") + .responseStatus(200) + .takenTime(10L) + .build()); + ApiLog cutoffLog = apiLogRepository.save(ApiLog.builder() + .requestUrl("/cutoff") + .requestMethod("GET") + .userId("2") + .clientIp("127.0.0.1") + .responseStatus(200) + .takenTime(20L) + .build()); + ApiLog retainedLog = apiLogRepository.save(ApiLog.builder() + .requestUrl("/retained") + .requestMethod("GET") + .userId("3") + .clientIp("127.0.0.1") + .responseStatus(200) + .takenTime(30L) + .build()); + setApiLogCreatedAt(expiredLog.getApiLogId(), now.minusDays(90).minusSeconds(1)); + setApiLogCreatedAt(cutoffLog.getApiLogId(), now.minusDays(90)); + setApiLogCreatedAt(retainedLog.getApiLogId(), now.minusDays(90).plusSeconds(1)); + + // when + RetentionCleanupService.RetentionCleanupResult result = retentionCleanupService.cleanupExpiredRetentionData(now); + + // then + assertThat(result.deletedApiLogs()).isEqualTo(1); + assertThat(apiLogRepository.findById(expiredLog.getApiLogId())).isEmpty(); + assertThat(apiLogRepository.findById(cutoffLog.getApiLogId())).isPresent(); + assertThat(apiLogRepository.findById(retainedLog.getApiLogId())).isPresent(); + } + + private void setApiLogCreatedAt(Long apiLogId, LocalDateTime createdAt) { + jdbcTemplate.update( + "UPDATE api_log SET created_at = ? WHERE api_log_id = ?", + createdAt, + apiLogId + ); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java index b2b0da8..bee95a9 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java @@ -7,11 +7,32 @@ import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; import devkor.ontime_back.entity.AccountDeletionFeedback; +import devkor.ontime_back.entity.DoneStatus; +import devkor.ontime_back.entity.Feedback; +import devkor.ontime_back.entity.FriendShip; +import devkor.ontime_back.entity.NotificationSchedule; +import devkor.ontime_back.entity.Place; +import devkor.ontime_back.entity.PreparationSchedule; +import devkor.ontime_back.entity.PreparationUser; import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.Schedule; import devkor.ontime_back.entity.SocialType; import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; +import devkor.ontime_back.entity.UserAlarmStatus; +import devkor.ontime_back.entity.UserDevice; import devkor.ontime_back.entity.UserSetting; import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; +import devkor.ontime_back.repository.FeedbackRepository; +import devkor.ontime_back.repository.FriendshipRepository; +import devkor.ontime_back.repository.NotificationScheduleRepository; +import devkor.ontime_back.repository.PlaceRepository; +import devkor.ontime_back.repository.PreparationScheduleRepository; +import devkor.ontime_back.repository.PreparationUserRepository; +import devkor.ontime_back.repository.ScheduleRepository; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserAlarmStatusRepository; +import devkor.ontime_back.repository.UserDeviceRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.repository.UserSettingRepository; import devkor.ontime_back.response.GeneralException; @@ -19,10 +40,14 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; +import java.time.Instant; +import java.time.LocalDateTime; import java.util.Collection; import java.util.NoSuchElementException; import java.util.Optional; @@ -50,6 +75,26 @@ class UserAuthServiceTest { private UserSettingRepository userSettingRepository; @Autowired private AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; + @Autowired + private FeedbackRepository feedbackRepository; + @Autowired + private FriendshipRepository friendshipRepository; + @Autowired + private NotificationScheduleRepository notificationScheduleRepository; + @Autowired + private PlaceRepository placeRepository; + @Autowired + private PreparationScheduleRepository preparationScheduleRepository; + @Autowired + private PreparationUserRepository preparationUserRepository; + @Autowired + private ScheduleRepository scheduleRepository; + @Autowired + private UserAlarmSettingRepository userAlarmSettingRepository; + @Autowired + private UserAlarmStatusRepository userAlarmStatusRepository; + @Autowired + private UserDeviceRepository userDeviceRepository; @Autowired private PasswordEncoder passwordEncoder; @@ -60,7 +105,17 @@ class UserAuthServiceTest { @AfterEach void tearDown() { accountDeletionFeedbackRepository.deleteAllInBatch(); + userAlarmStatusRepository.deleteAllInBatch(); + userDeviceRepository.deleteAllInBatch(); + notificationScheduleRepository.deleteAllInBatch(); + preparationScheduleRepository.deleteAllInBatch(); + scheduleRepository.deleteAllInBatch(); + preparationUserRepository.deleteAllInBatch(); + feedbackRepository.deleteAllInBatch(); + friendshipRepository.deleteAllInBatch(); + userAlarmSettingRepository.deleteAllInBatch(); userSettingRepository.deleteAllInBatch(); + placeRepository.deleteAllInBatch(); userRepository.deleteAllInBatch(); } @@ -345,6 +400,142 @@ void deleteUserWithoutFeedback(){ assertThat(accountDeletionFeedbackRepository.findAll()).isEmpty(); } + @DisplayName("소셜 계정 삭제 시 사용자 소유 데이터는 삭제하고 탈퇴 피드백만 익명화해 보존한다") + @ParameterizedTest + @EnumSource(SocialType.class) + void deleteSocialUserRemovesAssociatedDataAndRetainsAnonymizedDeletionFeedback(SocialType socialType) { + // given + User targetUser = User.builder() + .email(socialType.name().toLowerCase() + "@example.com") + .password(passwordEncoder.encode("password1234")) + .name("delete-target-" + socialType.name().toLowerCase()) + .role(Role.USER) + .socialType(socialType) + .socialId("social-id-" + socialType.name().toLowerCase()) + .firebaseToken("firebase-token") + .accessToken("access-token") + .refreshToken("refresh-token") + .socialLoginToken("provider-refresh-token") + .build(); + UserSetting userSetting = UserSetting.builder() + .userSettingId(UUID.randomUUID()) + .user(targetUser) + .build(); + targetUser.setUserSetting(userSetting); + userRepository.saveAndFlush(targetUser); + + User friendUser = userRepository.saveAndFlush(User.builder() + .email("friend-" + socialType.name().toLowerCase() + "@example.com") + .password(passwordEncoder.encode("password1234")) + .name("friend-" + socialType.name().toLowerCase()) + .role(Role.USER) + .build()); + + Place place = placeRepository.save(Place.builder() + .placeId(UUID.randomUUID()) + .placeName("Office") + .build()); + Schedule schedule = scheduleRepository.save(Schedule.builder() + .scheduleId(UUID.randomUUID()) + .user(targetUser) + .place(place) + .scheduleName("Release check") + .moveTime(20) + .scheduleTime(LocalDateTime.of(2026, 5, 9, 10, 0)) + .isChange(false) + .isStarted(false) + .doneStatus(DoneStatus.NOT_ENDED) + .scheduleSpareTime(10) + .latenessTime(null) + .scheduleNote("Delete cascade verification") + .build()); + preparationScheduleRepository.save(PreparationSchedule.builder() + .preparationScheduleId(UUID.randomUUID()) + .schedule(schedule) + .preparationName("Pack") + .preparationTime(5) + .build()); + notificationScheduleRepository.save(NotificationSchedule.builder() + .schedule(schedule) + .notificationTime(LocalDateTime.of(2026, 5, 9, 9, 45)) + .isSent(false) + .build()); + preparationUserRepository.save(PreparationUser.builder() + .preparationUserId(UUID.randomUUID()) + .user(targetUser) + .preparationName("Brush teeth") + .preparationTime(3) + .build()); + feedbackRepository.save(Feedback.builder() + .feedbackId(UUID.randomUUID()) + .user(targetUser) + .message("general feedback") + .createAt(LocalDateTime.of(2026, 5, 9, 8, 0)) + .build()); + friendshipRepository.save(FriendShip.builder() + .friendShipId(UUID.randomUUID()) + .requesterId(targetUser.getId()) + .receiverId(friendUser.getId()) + .acceptStatus("ACCEPTED") + .build()); + userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(targetUser)); + UserDevice userDevice = UserDevice.create(targetUser, "device-" + socialType.name().toLowerCase()); + userDevice.activate("ios", "1.0.0", "17.0", true, "native", "fcm", Instant.now()); + userDevice.bindSession("session-access-token", "session-refresh-token"); + userDevice.updateFirebaseToken("device-firebase-token"); + userDeviceRepository.save(userDevice); + UserAlarmStatus alarmStatus = UserAlarmStatus.create(targetUser, userDevice); + alarmStatus.replace( + Instant.now(), + LocalDateTime.of(2026, 5, 9, 0, 0), + LocalDateTime.of(2026, 5, 10, 0, 0), + LocalDateTime.of(2026, 5, 9, 9, 30), + LocalDateTime.of(2026, 5, 9, 10, 0), + "READY", + null, + "native", + "fcm", + 1, + "[\"" + schedule.getScheduleId() + "\"]", + 0, + null + ); + userAlarmStatusRepository.save(alarmStatus); + + UUID deletionFeedbackId = UUID.randomUUID(); + FeedbackAddDto feedbackAddDto = FeedbackAddDto.builder() + .feedbackId(deletionFeedbackId) + .message("delete feedback") + .build(); + + // when + Long deletedUserId = userAuthService.deleteUser(targetUser.getId(), feedbackAddDto); + + // then + assertThat(deletedUserId).isEqualTo(targetUser.getId()); + assertThat(userRepository.findById(targetUser.getId())).isEmpty(); + assertThat(userRepository.findById(friendUser.getId())).isPresent(); + assertThat(scheduleRepository.findById(schedule.getScheduleId())).isEmpty(); + assertThat(preparationScheduleRepository.count()).isZero(); + assertThat(notificationScheduleRepository.count()).isZero(); + assertThat(preparationUserRepository.count()).isZero(); + assertThat(feedbackRepository.count()).isZero(); + assertThat(friendshipRepository.count()).isZero(); + assertThat(userSettingRepository.findByUserId(targetUser.getId())).isEmpty(); + assertThat(userAlarmSettingRepository.findByUserId(targetUser.getId())).isEmpty(); + assertThat(userDeviceRepository.findByUserIdAndDeviceId(targetUser.getId(), userDevice.getDeviceId())).isEmpty(); + assertThat(userAlarmStatusRepository.findByUserDeviceUserDeviceId(userDevice.getUserDeviceId())).isEmpty(); + + AccountDeletionFeedback deletionFeedback = accountDeletionFeedbackRepository.findById(deletionFeedbackId) + .orElseThrow(); + assertThat(deletionFeedback.getDeletedUserId()).isEqualTo(targetUser.getId()); + assertThat(deletionFeedback.getSocialType()).isEqualTo(socialType); + assertThat(deletionFeedback.getMessage()).isEqualTo("delete feedback"); + assertThat(deletionFeedback.getEmailHash()).hasSize(64); + assertThat(deletionFeedback.getEmailHash()).doesNotContain(targetUser.getEmail()); + assertThat(deletionFeedback.getCreatedAt()).isNotNull(); + } + private UserSignUpDto getUserSignUpDto(String email, String password, String name) {