diff --git a/.github/workflows/api-testing.yml b/.github/workflows/api-testing.txt similarity index 100% rename from .github/workflows/api-testing.yml rename to .github/workflows/api-testing.txt diff --git a/.github/workflows/e2e-testing.yml b/.github/workflows/e2e-testing.yml new file mode 100644 index 000000000..7179c9ce8 --- /dev/null +++ b/.github/workflows/e2e-testing.yml @@ -0,0 +1,200 @@ +name: Deploy testing environment to EC2 + +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + outputs: + wekan_image_tag: ${{ steps.docker_image_build.outputs.tag }} + + steps: + - name: Checkout repository(omriza5/wekan) + uses: actions/checkout@v4 + + - name: Build and push docker image + id: docker_image_build + run: | + # Login to DockerHub + echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + # Use short commit SHA (first 7 characters) + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + TAG="${SHORT_SHA}-$(date +%Y%m%d-%H%M%S)" + echo "tag=$TAG" >> $GITHUB_OUTPUT + + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/wekan:$TAG . + + docker push ${{ secrets.DOCKERHUB_USERNAME }}/wekan:$TAG + + # Save the tag for later steps + echo "WEKAN_IMAGE_TAG=$TAG" >> $GITHUB_ENV + + - name: Create .env file + run: | + echo "WEKAN_IMAGE=omriza5/wekan:${WEKAN_IMAGE_TAG}" >> .env + + - name: Copy .env file to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.WEKAN_EC2_HOST_IP }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + source: ".env" + target: "/home/ubuntu/" + + - name: Copy docker-compose file to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.WEKAN_EC2_HOST_IP }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + source: "docker-compose.yml" + target: "/home/ubuntu/" + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.WEKAN_EC2_HOST_IP }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # Stop and remove containers with volumes + sudo docker compose down -v || true + + # Clean up everything including named volumes + sudo docker volume rm $(sudo docker volume ls -q) 2>/dev/null || true + + sudo docker stop $(sudo docker ps -aq) 2>/dev/null || true + sudo docker rm $(sudo docker ps -aq) 2>/dev/null || true + + # Remove all images to free space + sudo docker rmi $(sudo docker images -q) 2>/dev/null || true + + # Clean up networks (volumes already removed above) + sudo docker network prune -f || true + + echo "${{ secrets.DOCKERHUB_PASSWORD }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + + sudo docker compose pull + sudo docker compose up -d + + API-tests: + needs: deploy + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Create test user via Database + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.WEKAN_EC2_HOST_IP }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # Wait for Wekan to be fully ready + echo "Waiting for Wekan to start..." + for i in {1..24}; do + if curl -s http://localhost > /dev/null 2>&1; then + echo "Wekan is responding!" + break + fi + echo "Waiting... (attempt $i/24)" + sleep 5 + done + + # Create user directly in database with the exact structure from browser + echo "Creating test user directly in database..." + sudo docker exec wekan-db mongosh wekan --eval ' + // Remove existing user first + db.users.deleteMany({username: "omriza5"}); + + // Create user with exact structure from browser + const result = db.users.insertOne({ + _id: "omriza5_" + new Date().getTime(), + createdAt: new Date(), + services: { + password: { + bcrypt: "$2b$10$v9266B4sMuTCOgPsnIPibuxKoUwELIqPvTn7GQqGvvVibAEsmphsm" + }, + email: { + verificationTokens: [ + { + token: "token_" + Math.random().toString(36).substring(2), + address: "omriza5@gmail.com", + when: new Date() + } + ] + } + }, + username: "omriza5", + emails: [{ address: "omriza5@gmail.com", verified: false }], + isAdmin: true, + modifiedAt: new Date(), + profile: { + boardView: "board-view-swimlanes", + listSortBy: "-modifiedAt", + templatesBoardId: "", + cardTemplatesSwimlaneId: "", + listTemplatesSwimlaneId: "", + boardTemplatesSwimlaneId: "", + listWidths: {}, + listConstraints: {}, + autoWidthBoards: {}, + swimlaneHeights: {}, + keyboardShortcuts: false, + verticalScrollbars: true, + showWeekOfYear: true + }, + authenticationMethod: "password", + sessionData: {} + }); + + if (result.acknowledged) { + print("User omriza5 created successfully"); + } else { + print("Failed to create user"); + } + ' || echo "Failed to execute MongoDB command" + + # Verify user was created + echo "Verifying user creation..." + sudo docker exec wekan-db mongosh wekan --eval 'db.users.findOne({username: "omriza5"}, {username: 1, emails: 1, isAdmin: 1})' || echo "User verification failed" + + # Verify login works + echo "Testing login..." + LOGIN_RESPONSE=$(curl -s -w "HTTPSTATUS:%{http_code}" \ + -H "Content-type:application/json" \ + -X POST http://localhost/users/login \ + -d '{"username":"omriza5","password":"123456"}') + + LOGIN_CODE=$(echo $LOGIN_RESPONSE | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + if [[ "$LOGIN_CODE" == "200" ]]; then + echo "Login test successful" + else + echo "Login test failed (Code: $LOGIN_CODE)" + echo "Response: $(echo $LOGIN_RESPONSE | sed -e 's/HTTPSTATUS:.*//g')" + fi + + - name: Run API tests + env: + BASE_URL: ${{ secrets.WEKAN_URL }} + run: | + pytest --maxfail=5 --disable-warnings -v + diff --git a/.github/workflows/ui-testing.yml b/.github/workflows/ui-testing.txt similarity index 100% rename from .github/workflows/ui-testing.yml rename to .github/workflows/ui-testing.txt diff --git a/.gitignore b/.gitignore index e525a7965..a224f2396 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ pip-delete-this-directory.txt .coverage htmlcov/ *.pem +*.env diff --git a/docker-compose.yml b/docker-compose.yml index ce1dbbcf6..6a06e16f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,15 @@ services: - wekan-tier expose: - 27017 + ports: + - 27017:27017 volumes: - /etc/localtime:/etc/localtime:ro - wekan-db:/data/db - wekan-db-dump:/dump wekan: - image: ghcr.io/wekan/wekan:latest + image: ${WEKAN_IMAGE} container_name: wekan-app restart: always networks: @@ -33,6 +35,9 @@ services: - BIGEVENTS_PATTERN=NONE - BROWSER_POLICY_ENABLED=true - LDAP_BACKGROUND_SYNC_INTERVAL='' + - ACCOUNTS_LOCKOUT_UNKNOWN_USERS=false + - ACCOUNTS_REGISTRATION_VERIFY_EMAIL=false + - DISABLE_REGISTRATION=false depends_on: - wekandb volumes: diff --git a/models/users.js b/models/users.js index 404a332d8..98cfaa2be 100644 --- a/models/users.js +++ b/models/users.js @@ -546,9 +546,9 @@ Users.attachSchema( Users.allow({ update(userId, doc) { - const user = ReactiveCache.getUser(userId) || ReactiveCache.getCurrentUser(); - if (user?.isAdmin) - return true; + const user = + ReactiveCache.getUser(userId) || ReactiveCache.getCurrentUser(); + if (user?.isAdmin) return true; if (!user) { return false; } @@ -583,12 +583,14 @@ Users.allow({ // Non-Admin users can not change to Admin Users.deny({ update(userId, board, fieldNames) { - return _.contains(fieldNames, 'isAdmin') && !ReactiveCache.getCurrentUser().isAdmin; + return ( + _.contains(fieldNames, 'isAdmin') && + !ReactiveCache.getCurrentUser().isAdmin + ); }, fetch: [], }); - // Search a user in the complete server database by its name, username or emails adress. This // is used for instance to add a new user to a board. UserSearchIndex = new Index({ @@ -714,7 +716,7 @@ Users.helpers({ orgIdsUserBelongs() { let ret = ''; if (this.orgs) { - ret = this.orgs.map(org => org.orgId).join(','); + ret = this.orgs.map((org) => org.orgId).join(','); } return ret; }, @@ -732,7 +734,7 @@ Users.helpers({ teamIdsUserBelongs() { let ret = ''; if (this.teams) { - ret = this.teams.map(team => team.teamId).join(','); + ret = this.teams.map((team) => team.teamId).join(','); } return ret; }, @@ -801,7 +803,7 @@ Users.helpers({ }, getListWidths() { - const { listWidths = {}, } = this.profile || {}; + const { listWidths = {} } = this.profile || {}; return listWidths; }, getListWidth(boardId, listId) { @@ -888,8 +890,13 @@ Users.helpers({ const notification = notifications[index]; // this preserves their db sort order for editing notification.dbIndex = index; - if (!notification.activityObj && typeof(notification.activity) === 'string') { - notification.activityObj = ReactiveMiniMongoIndex.getActivityWithId(notification.activity); + if ( + !notification.activityObj && + typeof notification.activity === 'string' + ) { + notification.activityObj = ReactiveMiniMongoIndex.getActivityWithId( + notification.activity, + ); } } // newest first. don't use reverse() because it changes the array inplace, so sometimes the array is reversed twice and oldest items at top again @@ -1360,11 +1367,13 @@ if (Meteor.isServer) { check(userTeamsArray, Array); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (fullname.includes('/') || - username.includes('/') || - email.includes('/') || - initials.includes('/')) { - return false; + if ( + fullname.includes('/') || + username.includes('/') || + email.includes('/') || + initials.includes('/') + ) { + return false; } if (ReactiveCache.getCurrentUser()?.isAdmin) { const nUsersWithUsername = ReactiveCache.getUsers({ @@ -1408,9 +1417,8 @@ if (Meteor.isServer) { check(userId, String); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (username.includes('/') || - userId.includes('/')) { - return false; + if (username.includes('/') || userId.includes('/')) { + return false; } if (ReactiveCache.getCurrentUser()?.isAdmin) { const nUsersWithUsername = ReactiveCache.getUsers({ @@ -1432,9 +1440,8 @@ if (Meteor.isServer) { check(username, String); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (username.includes('/') || - email.includes('/')) { - return false; + if (username.includes('/') || email.includes('/')) { + return false; } if (ReactiveCache.getCurrentUser()?.isAdmin) { if (Array.isArray(email)) { @@ -1472,10 +1479,12 @@ if (Meteor.isServer) { check(userId, String); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (username.includes('/') || - email.includes('/') || - userId.includes('/')) { - return false; + if ( + username.includes('/') || + email.includes('/') || + userId.includes('/') + ) { + return false; } if (ReactiveCache.getCurrentUser()?.isAdmin) { if (Array.isArray(email)) { @@ -1498,9 +1507,8 @@ if (Meteor.isServer) { check(userId, String); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (email.includes('/') || - userId.includes('/')) { - return false; + if (email.includes('/') || userId.includes('/')) { + return false; } if (ReactiveCache.getCurrentUser()?.isAdmin) { Users.update(userId, { @@ -1520,9 +1528,8 @@ if (Meteor.isServer) { check(userId, String); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (initials.includes('/') || - userId.includes('/')) { - return false; + if (initials.includes('/') || userId.includes('/')) { + return false; } if (ReactiveCache.getCurrentUser()?.isAdmin) { Users.update(userId, { @@ -1538,9 +1545,8 @@ if (Meteor.isServer) { check(boardId, String); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (username.includes('/') || - boardId.includes('/')) { - return false; + if (username.includes('/') || boardId.includes('/')) { + return false; } const inviter = ReactiveCache.getCurrentUser(); const board = ReactiveCache.getBoard(boardId); @@ -1586,9 +1592,8 @@ if (Meteor.isServer) { username = email.substring(0, posAt); // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (username.includes('/') || - email.includes('/')) { - return false; + if (username.includes('/') || email.includes('/')) { + return false; } const newUserId = Accounts.createUser({ username, @@ -1618,51 +1623,52 @@ if (Meteor.isServer) { subBoard.addMember(user._id); user.addInvite(subBoard._id); } - } try { - const fullName = - inviter.profile !== undefined && - inviter.profile.fullname !== undefined - ? inviter.profile.fullname - : ''; - const userFullName = - user.profile !== undefined && user.profile.fullname !== undefined - ? user.profile.fullname - : ''; - const params = { - user: - userFullName != '' - ? userFullName + ' (' + user.username + ' )' - : user.username, - inviter: - fullName != '' - ? fullName + ' (' + inviter.username + ' )' - : inviter.username, - board: board.title, - url: board.absoluteUrl(), - }; - // Get the recipient user's language preference for the email - const lang = user.getLanguage(); + } + try { + const fullName = + inviter.profile !== undefined && + inviter.profile.fullname !== undefined + ? inviter.profile.fullname + : ''; + const userFullName = + user.profile !== undefined && user.profile.fullname !== undefined + ? user.profile.fullname + : ''; + const params = { + user: + userFullName != '' + ? userFullName + ' (' + user.username + ' )' + : user.username, + inviter: + fullName != '' + ? fullName + ' (' + inviter.username + ' )' + : inviter.username, + board: board.title, + url: board.absoluteUrl(), + }; + // Get the recipient user's language preference for the email + const lang = user.getLanguage(); - // Add code to send invitation with EmailLocalization - if (typeof EmailLocalization !== 'undefined') { - EmailLocalization.sendEmail({ - to: user.emails[0].address, - from: Accounts.emailTemplates.from, - subject: 'email-invite-subject', - text: 'email-invite-text', - params: params, - language: lang, - userId: user._id - }); - } else { - // Fallback if EmailLocalization is not available - Email.send({ - to: user.emails[0].address, - from: Accounts.emailTemplates.from, - subject: TAPi18n.__('email-invite-subject', params, lang), - text: TAPi18n.__('email-invite-text', params, lang), - }); - } + // Add code to send invitation with EmailLocalization + if (typeof EmailLocalization !== 'undefined') { + EmailLocalization.sendEmail({ + to: user.emails[0].address, + from: Accounts.emailTemplates.from, + subject: 'email-invite-subject', + text: 'email-invite-text', + params: params, + language: lang, + userId: user._id, + }); + } else { + // Fallback if EmailLocalization is not available + Email.send({ + to: user.emails[0].address, + from: Accounts.emailTemplates.from, + subject: TAPi18n.__('email-invite-subject', params, lang), + text: TAPi18n.__('email-invite-text', params, lang), + }); + } } catch (e) { throw new Meteor.Error('email-fail', e.message); } @@ -1688,7 +1694,9 @@ if (Meteor.isServer) { }, isImpersonated(userId) { check(userId, String); - const isImpersonated = ReactiveCache.getImpersonatedUser({ userId: userId }); + const isImpersonated = ReactiveCache.getImpersonatedUser({ + userId: userId, + }); return isImpersonated; }, setUsersTeamsTeamDisplayName(teamId, teamDisplayName) { @@ -1760,15 +1768,12 @@ if (Meteor.isServer) { }, ]; - // Prevent Hyperlink Injection https://github.com/wekan/wekan/issues/5176 // Thanks to mc-marcy and xet7 ! - if (user.username.includes('/') || - email.includes('/')) { - return false; + if (user.username.includes('/') || email.includes('/')) { + return false; } - const initials = user.services.oidc.fullname .split(/\s+/) .reduce((memo, word) => { @@ -1817,7 +1822,8 @@ if (Meteor.isServer) { return user; } - const disableRegistration = ReactiveCache.getCurrentSetting().disableRegistration; + const disableRegistration = + ReactiveCache.getCurrentSetting().disableRegistration; // If this is the first Authentication by the ldap and self registration disabled if (disableRegistration && options && options.ldap) { user.authenticationMethod = 'ldap'; @@ -1909,8 +1915,13 @@ if (Meteor.isServer) { modifiedAt: -1, }); // Avatar URLs from CollectionFS to Meteor-Files, at users collection avatarUrl field: - Users.find({ "profile.avatarUrl": { $regex: "/cfs/files/avatars/" } }).forEach(function (doc) { - doc.profile.avatarUrl = doc.profile.avatarUrl.replace("/cfs/files/avatars/", "/cdn/storage/avatars/"); + Users.find({ + 'profile.avatarUrl': { $regex: '/cfs/files/avatars/' }, + }).forEach(function (doc) { + doc.profile.avatarUrl = doc.profile.avatarUrl.replace( + '/cfs/files/avatars/', + '/cdn/storage/avatars/', + ); // Try to fix Users.save is not a fuction, by commenting it out: //Users.save(doc); }); @@ -2133,7 +2144,8 @@ if (Meteor.isServer) { } //invite user to corresponding boards - const disableRegistration = ReactiveCache.getCurrentSetting().disableRegistration; + const disableRegistration = + ReactiveCache.getCurrentSetting().disableRegistration; // If ldap, bypass the inviation code if the self registration isn't allowed. // TODO : pay attention if ldap field in the user model change to another content ex : ldap field to connection_type if (doc.authenticationMethod !== 'ldap' && disableRegistration) { diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py index 294875ed6..aa2a09c20 100644 --- a/tests/auth/test_login.py +++ b/tests/auth/test_login.py @@ -25,6 +25,7 @@ class TestLogin: assert response.status_code == 200 json_response = response.json() + assert 'token' in json_response assert isinstance(json_response['token'], str) assert len(json_response['token']) > 0 @@ -43,6 +44,7 @@ class TestLogin: assert response.status_code in [400, 401, 404] json_response = response.json() + assert 'error' in json_response assert json_response['error'] == 'not-found' assert 'reason' in json_response diff --git a/tests/board/test_board.py b/tests/board/test_board.py index 062721eaf..b12f00943 100644 --- a/tests/board/test_board.py +++ b/tests/board/test_board.py @@ -13,7 +13,6 @@ class TestBoard: 'password': '123456' } - print("🔐 Getting authentication token...") response = requests.post(f"{base_url}/users/login", data=login_data) if response.status_code == 200: @@ -22,38 +21,16 @@ class TestBoard: # Store token and user info in class request.cls.auth_token = json_response['token'] request.cls.user_id = json_response.get('id', '') - print(f"✅ Token obtained: {request.cls.auth_token[:20]}...") - print(f"✅ User ID obtained: {request.cls.user_id[:20]}...") else: request.cls.auth_token = None - print(f"❌ Login failed: {json_response}") else: request.cls.auth_token = None - print(f"❌ Login request failed: {response.status_code}") def test_health_check(self): """Test basic health check""" response = requests.get(f"{base_url}") assert response.status_code == 200 - - def test_get_user_boards(self): - """Test getting information about boards of user""" - if not self.auth_token: - pytest.skip("No authentication token available") - - response = requests.get( - f"{base_url}/api/users/{self.user_id}/boards", - headers={"Authorization": f"Bearer {self.auth_token}"} - ) - - assert response.status_code == 200 - - # Should return a list of boards - boards_data = response.json() - assert isinstance(boards_data, list), "Response should be a list of boards" - assert "title" in boards_data[0], "First board object should have a 'title' key" - def test_create_board_minimal(self): """Test creating a board with minimal required fields""" if not self.auth_token: @@ -186,9 +163,6 @@ class TestBoard: data=board_data ) - print(f"🚫 Unauthorized creation status: {response.status_code}") - print(f"🚫 Unauthorized response: {response.text[:200]}") - # Should require authentication assert response.status_code in [400, 401, 403], "Should require authentication" @@ -202,7 +176,5 @@ class TestBoard: headers={"Authorization": f"Bearer {self.auth_token}"} ) - print(f"📋 Get boards API status: {response.json()}") - # Should work with authentication assert response.status_code in [200, 204]