From 5a0537ea3be98e4075edde3b71c97cd3a37860b5 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 20:04:20 +1000 Subject: [PATCH 1/2] chore: T2 2026 platform, CI and dependency updates Brings the T2 2026 platform work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit Co-authored-by: Ronit Khokhar Co-authored-by: Maple Fox Co-authored-by: luvya9203 Co-authored-by: jmirchh75 Co-authored-by: mudith-perera Co-authored-by: JOSHUA ERICKSON Co-authored-by: Eloise Ridder-Strickland Co-authored-by: blankb0t --- .dockerignore | 25 + .env.example | 11 + .github/dependabot.yml | 14 + .github/workflows/codeql.yml | 25 +- .github/workflows/deployment.yml | 75 +- .github/workflows/production-images.yml | 56 + .github/workflows/push.yml | 386 +++-- .github/workflows/rubocop.yml | 11 +- .gitignore | 11 +- .overcommit.yml | 22 - .rspec | 2 - .rubocop.yml | 5 +- .rubocop_todo.yml | 1398 +++++++++-------- .ruby-version | 1 + .tool-versions | 1 + Dockerfile | 12 +- FETCH_HEAD | 0 Gemfile | 14 +- Gemfile.lock | 432 +++-- README.md | 37 +- Rakefile | 0 app/api/activity_types_public_api.rb | 9 +- app/api/campuses_public_api.rb | 9 +- app/api/entities/task_status_entity.rb | 10 + app/api/feedback/feedback_chip_api.rb | 30 +- app/api/group_sets_api.rb | 9 +- app/api/task_statuses_api.rb | 8 + app/api/units_api.rb | 42 + app/helpers/authorisation_helpers.rb | 29 +- app/models/feedback/feedback_chip.rb | 26 +- bin/rails | 4 + config/application.rb | 34 +- config/database.yml | 4 + config/routes.rb | 1 + config/schedule.rb | 5 - ...0260922010000_create_courseflow_planner.rb | 23 + db/schema.rb | 217 ++- dependabot.yml | 9 - docker-bake.ci.hcl | 40 + docker-compose.yml | 20 +- docs/README_FOR_APP | 2 - docs/courseflow/README.md | 121 ++ docs/courseflow/sample-catalog.json | 12 + jplag.Dockerfile | 10 +- lib/shell/pdfgen_entry_point.sh | 30 +- lib/tasks/courseflow.rake | 13 + lib/tasks/maintenance.rake | 19 +- lib/tasks/populate.rake | 6 +- script/benchmark_notifications.rb | 141 ++ script/plan_test_shard_worker.rb | 67 + script/prepare_test_database.sh | 14 + script/run_test_shard_worker.sh | 294 ++++ script/test_inventory.rb | 75 + script/test_shard.rb | 548 +++++++ test/api/api_root_test.rb | 88 ++ test/api/comments/extension_test.rb | 6 +- test/api/courseflow_api_test.rb | 215 +++ .../feedback_chip_authorization_test.rb | 177 +++ test/api/overseer_steps_api_test.rb | 104 ++ test/api/project_history_test.rb | 100 ++ test/api/settings_test.rb | 152 +- test/api/task_statuses_api_test.rb | 36 + test/api/tci_33_test.rb | 24 + test/api/tii/tii_hook_test.rb | 75 +- test/api/units_csv_audit_test.rb | 92 ++ .../application_cable/connection_test.rb | 11 - test/config/database_yml_test.rb | 24 + test/config/pdfgen_config_test.rb | 43 + test/config/release_configuration_test.rb | 211 +++ test/config/rubocop_configuration_test.rb | 57 + .../student_import_weeks_before_test.rb | 23 + test/controllers/readiness_controller_test.rb | 28 + test/dx_a02_test.rb | 14 + test/integration/.keep | 0 test/lib/production_boot_guard_test.rb | 83 + test/lib/test_helper_test.rb | 53 + test/lib/test_shard_test.rb | 400 +++++ .../sentry_tunnel_middleware_test.rb | 72 + test/models/courseflow_test.rb | 148 ++ test/models/overseer_image_test.rb | 27 + .../authentication_callback_security_test.rb | 45 + test/services/readiness_check_unit_test.rb | 95 ++ test/shell/production_runtime_test.rb | 107 ++ .../communication_recipient_scope_test.rb | 43 + test/test_helper.rb | 20 +- texlive.Dockerfile | 28 +- 86 files changed, 5796 insertions(+), 1224 deletions(-) create mode 100644 .env.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/production-images.yml delete mode 100644 .overcommit.yml delete mode 100644 .rspec create mode 100644 .ruby-version create mode 100644 .tool-versions delete mode 100644 FETCH_HEAD mode change 100644 => 100755 Rakefile create mode 100644 app/api/entities/task_status_entity.rb create mode 100644 app/api/task_statuses_api.rb create mode 100755 bin/rails delete mode 100644 config/schedule.rb create mode 100644 db/migrate/20260922010000_create_courseflow_planner.rb delete mode 100644 dependabot.yml create mode 100644 docker-bake.ci.hcl delete mode 100644 docs/README_FOR_APP create mode 100644 docs/courseflow/README.md create mode 100644 docs/courseflow/sample-catalog.json create mode 100644 lib/tasks/courseflow.rake create mode 100644 script/benchmark_notifications.rb create mode 100755 script/plan_test_shard_worker.rb create mode 100755 script/prepare_test_database.sh create mode 100755 script/run_test_shard_worker.sh create mode 100755 script/test_inventory.rb create mode 100755 script/test_shard.rb create mode 100644 test/api/api_root_test.rb create mode 100644 test/api/courseflow_api_test.rb create mode 100644 test/api/feedback/feedback_chip_authorization_test.rb create mode 100644 test/api/overseer_steps_api_test.rb create mode 100644 test/api/project_history_test.rb create mode 100644 test/api/task_statuses_api_test.rb create mode 100644 test/api/tci_33_test.rb create mode 100644 test/api/units_csv_audit_test.rb delete mode 100644 test/channels/application_cable/connection_test.rb create mode 100644 test/config/database_yml_test.rb create mode 100644 test/config/pdfgen_config_test.rb create mode 100644 test/config/release_configuration_test.rb create mode 100644 test/config/rubocop_configuration_test.rb create mode 100644 test/config/student_import_weeks_before_test.rb create mode 100644 test/controllers/readiness_controller_test.rb create mode 100644 test/dx_a02_test.rb delete mode 100644 test/integration/.keep create mode 100644 test/lib/production_boot_guard_test.rb create mode 100644 test/lib/test_helper_test.rb create mode 100644 test/lib/test_shard_test.rb create mode 100644 test/middleware/sentry_tunnel_middleware_test.rb create mode 100644 test/models/courseflow_test.rb create mode 100644 test/security/authentication_callback_security_test.rb create mode 100644 test/services/readiness_check_unit_test.rb create mode 100644 test/shell/production_runtime_test.rb create mode 100644 test/sidekiq/communication_recipient_scope_test.rb diff --git a/.dockerignore b/.dockerignore index d8fd5e2e36..783770b90c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,32 @@ Dockerfile .git +.github +.docker +.bundle +.env +.env.* +!.env.example +.npmrc +.gem/credentials +.ssh +.aws +.config/gcloud build +coverage dist +log node_modules +tmp vendor student-work +config/master.key +config/credentials +config/credentials.yml.enc +**/*.key +**/*.pem +**/*.p12 +**/*.pfx +**/*.jks +**/*.keystore +test +test_files diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..fbfc423f8a --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Optional values for development through the legacy root docker-compose.yml. +# Copy to .env. Database authentication remains the safe default. Never commit +# an institution credential or reuse a production registration. +DF_AUTH_METHOD=database +DF_AAF_ISSUER_URL= +DF_AAF_AUDIENCE_URL=http://localhost:3000 +DF_AAF_CALLBACK_URL=http://localhost:3000/api/auth/jwt +DF_AAF_IDENTITY_PROVIDER_URL= +DF_AAF_UNIQUE_URL= +DF_AAF_AUTH_SIGNOUT_URL= +DF_SECRET_KEY_AAF= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..37c64301f7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# Set update schedule for GitHub Actions and Ruby dependencies + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" + - package-ecosystem: "bundler" + directory: "/" + schedule: + # Check for updates to Ruby gems every week + interval: "weekly" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8bf61dea80..c50e4787fe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,16 +13,23 @@ name: "CodeQL" on: push: - branches: ["development"] - pull_request: - # The branches below must be a subset of the branches above - branches: ["development"] + branches: ["11.0.x", "development"] + # CodeQL is a required check, so it must report for pull requests targeting + # any protected shared branch rather than only the branches listed above. + pull_request: {} schedule: - cron: "45 20 * * 3" +# A push to an open pull request would otherwise start a second analysis while +# the first is still running. Cancel the superseded run so only the newest head +# of each ref is analysed. +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + jobs: analyze: - name: Analyze + name: CodeQL runs-on: ubuntu-latest permissions: actions: read @@ -38,11 +45,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@6d786de4d6f3531a740e445b53a42b622bbbace8 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -55,7 +62,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@6d786de4d6f3531a740e445b53a42b622bbbace8 # v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -68,4 +75,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@6d786de4d6f3531a740e445b53a42b622bbbace8 # v3 diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index c9461b31e7..102f6d189a 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -1,14 +1,10 @@ -name: create-doubtfire-deployment +name: Legacy image validation (non-publishing) on: - push: - tags: - - "v*" - # branches: - # - '*.x' - # - 'development' - # - 'main' - deployment: workflow_dispatch: + +permissions: + contents: read + jobs: docker-deploy-development-image: if: github.repository_owner == 'doubtfire-lms' @@ -16,30 +12,25 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Setup meta for development image id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: lmsdoubtfire/doubtfire-api tags: | type=semver,pattern={{major}}.{{minor}}.x-dev + type=sha,prefix=manual- - name: Build and push api server id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: false tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: ${{ steps.docker_meta.outputs.labels }} - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} docker-api-server: @@ -48,18 +39,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Setup meta for api server id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: lmsdoubtfire/apiServer tags: | @@ -68,15 +53,18 @@ jobs: type=semver,pattern=prod-{{version}} type=semver,pattern=prod-{{major}}.{{minor}} type=semver,pattern=prod-{{major}} + type=sha,prefix=manual- - name: Build and push api server id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: file: deployApi.Dockerfile context: . - push: ${{ github.event_name != 'pull_request' }} + push: false tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: ${{ steps.docker_meta.outputs.labels }} + sbom: true + provenance: mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} docker-app-server: @@ -85,18 +73,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Setup meta for app server id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: lmsdoubtfire/appServer tags: | @@ -105,14 +87,17 @@ jobs: type=semver,pattern=prod-{{version}} type=semver,pattern=prod-{{major}}.{{minor}} type=semver,pattern=prod-{{major}} + type=sha,prefix=manual- - name: Build and push app server id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: file: deployAppSvr.Dockerfile context: . tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - push: ${{ github.event_name != 'pull_request' }} + labels: ${{ steps.docker_meta.outputs.labels }} + push: false + sbom: true + provenance: mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/production-images.yml b/.github/workflows/production-images.yml new file mode 100644 index 0000000000..c3e75d2bb8 --- /dev/null +++ b/.github/workflows/production-images.yml @@ -0,0 +1,56 @@ +name: Production image builds + +on: + pull_request: + push: + branches: + - "*.x" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: API + dockerfile: deployApi.Dockerfile + cache_scope: production-api + - name: app worker + dockerfile: deployAppSvr.Dockerfile + cache_scope: production-app + - name: TeX Live helper + dockerfile: texlive.Dockerfile + cache_scope: production-texlive + - name: JPlag helper + dockerfile: jplag.Dockerfile + cache_scope: production-jplag + + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Build production image without publishing + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: false + sbom: true + provenance: mode=max + cache-from: type=gha,scope=${{ matrix.cache_scope }} + cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }} diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index dbd2b1061c..a4ba8dceaf 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,16 +1,29 @@ name: Unit Tests on: push: + branches: + - "*.x" + - development + - main + - master + tags: + - "v*" paths-ignore: - "*.md" - "docs/**" - pull_request: - paths-ignore: - - "*.md" - - "docs/**" + # This check is required by the repository ruleset, so it must report for + # every pull request, including documentation-only changes. + pull_request: {} + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true env: RAILS_ENV: "test" + DOCKER_BUILD_RECORD_UPLOAD: "false" + DOCKER_BUILD_SUMMARY: "false" DF_STUDENT_WORK_DIR: "/student-work" DF_INSTITUTION_HOST: "http://localhost:3000" DF_INSTITUTION_PRODUCT_NAME: "OnTrack" @@ -33,8 +46,20 @@ env: LTI_ENABLED: true jobs: - unit-tests: + unit_test_shards: + name: Unit Tests (worker ${{ matrix.worker }}/5) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + worker: [1, 2, 3, 4, 5] + env: + TEST_SHARD_COUNT: "20" + TEST_SHARD_WORKER_COUNT: "5" + TEST_SHARD_WORKER_NUMBER: ${{ matrix.worker }} + TEST_SHARD_WORKER_PLAN: tmp/test-shard-worker-plan.tsv + CI_IMAGE_CACHE_WRITE: ${{ github.event_name != 'pull_request' }} + SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE: "true" services: mariadb: image: mariadb @@ -43,148 +68,233 @@ jobs: MARIADB_PASSWORD: ${{ env.DF_TEST_DB_PASSWORD }} MARIADB_DATABASE: ${{ env.DF_TEST_DB_DATABASE }} MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: yes # This is required or the healthcheck script can't connect to the db - options: --health-cmd "/usr/local/bin/healthcheck.sh --connect --innodb_initialized" --health-interval 10s --health-timeout 5s --health-retries 5 + options: --health-cmd "/usr/local/bin/healthcheck.sh --connect --innodb_initialized" --health-interval 1s --health-timeout 5s --health-retries 60 redis: image: redis:7.0 options: --health-cmd "redis-cli ping | grep PONG" --health-interval 1s --health-timeout 5s --health-retries 5 steps: - name: Checkout code - uses: actions/checkout@v4 - - name: Set up docker buildx - uses: docker/setup-buildx-action@v3 - - name: Build TexLive image - uses: docker/build-push-action@v5 - with: - context: . - file: texlive.Dockerfile - push: false - load: true - tags: doubtfire-texlive-development:local - cache-from: type=gha,scope=texlive - cache-to: type=gha,mode=max,scope=texlive - - name: Build JPlag image - uses: docker/build-push-action@v5 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Resolve the job service network + id: service_network + run: | + database_container_id="$(docker ps --filter ancestor=mariadb --format '{{.ID}}' | head -n 1)" + if [ -z "$database_container_id" ]; then + echo "Unable to find the MariaDB service container." + exit 1 + fi + service_network="$( + docker inspect \ + --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' \ + "$database_container_id" | + head -n 1 + )" + if [ -z "$service_network" ]; then + echo "Unable to resolve the GitHub Actions service network." + exit 1 + fi + echo "name=$service_network" >> "$GITHUB_OUTPUT" + - name: Plan test shard + id: plan_shard + run: | + TEST_SHARD_MANIFEST_DIR=tmp/test-shard-manifests \ + TEST_SHARD_SELECTOR_INVENTORY=tmp/test-selector-inventory.txt \ + TEST_SHARD_GITHUB_OUTPUT="$GITHUB_OUTPUT" \ + ruby script/plan_test_shard_worker.rb + echo "seed_date=$(date -u +%F)" >> "$GITHUB_OUTPUT" + - name: Restore populated test database + id: seeded_database_cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - context: . - file: jplag.Dockerfile - push: false - load: true - tags: doubtfire-jplag-development:local - cache-from: type=gha,scope=jplag - cache-to: type=gha,mode=max,scope=jplag - - name: Build base doubtfire-api development image - uses: docker/build-push-action@v5 + path: | + tmp/ci-seeded-database.sql.gz + tmp/ci-seeded-student-work.tar.gz + key: seeded-test-database-v5-${{ runner.os }}-${{ steps.plan_shard.outputs.seed_date }}-${{ hashFiles('.github/workflows/push.yml', '.dockerignore', 'Dockerfile', 'docker-bake.ci.hcl', 'Gemfile', 'Gemfile.lock', 'Rakefile', 'app/**/*', 'config/**/*', 'db/**/*', 'docker-entrypoint.sh', 'lib/**/*', 'script/prepare_test_database.sh', 'test/factories/**/*', 'test_files/**/*') }} + - name: Set up docker buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - name: Build test images concurrently + uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 with: - context: . - push: false + source: . + files: docker-bake.ci.hcl + targets: ${{ steps.plan_shard.outputs.bake_targets }} load: true - tags: doubtfire-api-development:local - cache-from: type=gha,scope=doubtfire-api - cache-to: type=gha,mode=max,scope=doubtfire-api - - name: Start TexLive service - uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a - with: - image: doubtfire-texlive-development:local - options: > - --name ${{ env.LATEX_CONTAINER_NAME }} - -v ${{ github.workspace }}/student-work:/student-work - -v ${{ github.workspace }}/public/assets/images:/doubtfire/public/assets/images - -v ${{ github.workspace }}/test_files:/doubtfire/test_files - -v ${{ github.workspace }}/tmp/rails-latex:/workdir/texlive-latex - --detach - run: sleep infinity - - name: Test TexLive container - uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a - with: - image: doubtfire-api-development:local - options: > - -t - -v ${{ github.workspace }}:/doubtfire - -v /var/run/docker.sock:/var/run/docker.sock - run: docker exec -t ${{ env.LATEX_CONTAINER_NAME }} lualatex -v - - name: Start JPlag service - uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a - with: - image: doubtfire-jplag-development:local - options: > - --name jplag - -v ${{ github.workspace }}/student-work:/student-work - -v ${{ github.workspace }}/tmp/jplag:/tmp/jplag - -v ${{ github.workspace }}/test_files/submissions/jplag:/test_files - --detach - run: sleep infinity - - name: Test JPlag service - uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a - with: - image: doubtfire-api-development:local - options: > - -t - -v ${{ github.workspace }}:/doubtfire - -v /var/run/docker.sock:/var/run/docker.sock - run: docker exec -e TERM=xterm -i jplag java -jar /jplag/jplag-jar-with-dependencies.jar /test_files -l java --similarity-threshold=0.30 -M RUN -r test.jplag - - name: Populate database - uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a + - name: Prepare populated database + env: + SEEDED_DATABASE_CACHE_HIT: ${{ steps.seeded_database_cache.outputs.cache-hit }} + run: | + docker run --rm \ + --network "${{ steps.service_network.outputs.name }}" \ + --volume "$GITHUB_WORKSPACE:/doubtfire" \ + --volume "$GITHUB_WORKSPACE/student-work:/student-work" \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --env RAILS_ENV \ + --env DF_STUDENT_WORK_DIR \ + --env DF_INSTITUTION_HOST \ + --env DF_INSTITUTION_PRODUCT_NAME \ + --env DF_SECRET_KEY_BASE \ + --env DF_SECRET_KEY_ATTR \ + --env DF_SECRET_KEY_DEVISE \ + --env DF_TEST_DB_ADAPTER \ + --env DF_TEST_DB_HOST \ + --env DF_TEST_DB_DATABASE \ + --env DF_TEST_DB_USERNAME \ + --env DF_TEST_DB_PASSWORD \ + --env OVERSEER_ENABLED \ + --env DF_ENCRYPTION_PRIMARY_KEY \ + --env DF_ENCRYPTION_DETERMINISTIC_KEY \ + --env DF_ENCRYPTION_KEY_DERIVATION_SALT \ + --env DF_REDIS_SIDEKIQ_URL \ + --env LATEX_CONTAINER_NAME \ + --env LATEX_BUILD_PATH \ + --env LTI_SHARED_API_SECRET \ + --env LTI_ENABLED \ + --env SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE \ + --env SEEDED_DATABASE_CACHE_HIT \ + doubtfire-api-ci:local \ + script/prepare_test_database.sh + - name: Verify populated database schema + run: git diff --exit-code -- db/schema.rb + - name: Snapshot populated test database + if: ${{ steps.seeded_database_cache.outputs.cache-hit != 'true' }} + run: | + set -euo pipefail + database_container_id="$(docker ps --filter ancestor=mariadb --format '{{.ID}}' | head -n 1)" + if [ -z "$database_container_id" ]; then + echo "Unable to find the MariaDB service container." + exit 1 + fi + mkdir -p tmp + docker exec "$database_container_id" mariadb-dump \ + --user="$DF_TEST_DB_USERNAME" \ + --password="$DF_TEST_DB_PASSWORD" \ + --single-transaction \ + --skip-comments \ + "$DF_TEST_DB_DATABASE" | + gzip -1 > tmp/ci-seeded-database.sql.gz + tar -C student-work -czf tmp/ci-seeded-student-work.tar.gz . + - name: Save populated test database + if: ${{ steps.seeded_database_cache.outputs.cache-hit != 'true' && matrix.worker == 1 }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - image: doubtfire-api-development:local - options: > - -v ${{ github.workspace }}:/doubtfire - -v ${{ github.workspace }}/student-work:/student-work - -v /var/run/docker.sock:/var/run/docker.sock - -e RAILS_ENV - -e DF_STUDENT_WORK_DIR - -e DF_INSTITUTION_HOST - -e DF_INSTITUTION_PRODUCT_NAME - -e DF_SECRET_KEY_BASE - -e DF_SECRET_KEY_ATTR - -e DF_SECRET_KEY_DEVISE - -e DF_TEST_DB_ADAPTER - -e DF_TEST_DB_HOST - -e DF_TEST_DB_DATABASE - -e DF_TEST_DB_USERNAME - -e DF_TEST_DB_PASSWORD - -e OVERSEER_ENABLED - -e DF_ENCRYPTION_PRIMARY_KEY - -e DF_ENCRYPTION_DETERMINISTIC_KEY - -e DF_ENCRYPTION_KEY_DERIVATION_SALT - -e DF_REDIS_SIDEKIQ_URL - -e LATEX_CONTAINER_NAME - -e LATEX_BUILD_PATH - -e LTI_SHARED_API_SECRET - -e LTI_ENABLED - run: bundle exec rake db:populate + path: | + tmp/ci-seeded-database.sql.gz + tmp/ci-seeded-student-work.tar.gz + key: ${{ steps.seeded_database_cache.outputs.cache-primary-key }} - name: Run unit tests - uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a + env: + CI_SERVICE_NETWORK: ${{ steps.service_network.outputs.name }} + run: script/run_test_shard_worker.sh + - name: Upload test shard evidence + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - image: doubtfire-api-development:local - options: > - -v ${{ github.workspace }}:/doubtfire - -v ${{ github.workspace }}/student-work:/student-work - -v /var/run/docker.sock:/var/run/docker.sock - -v ${{ github.workspace }}/tmp/jplag:/tmp/jplag - -e RAILS_ENV - -e DF_STUDENT_WORK_DIR - -e DF_INSTITUTION_HOST - -e DF_INSTITUTION_PRODUCT_NAME - -e DF_SECRET_KEY_BASE - -e DF_SECRET_KEY_ATTR - -e DF_SECRET_KEY_DEVISE - -e DF_TEST_DB_ADAPTER - -e DF_TEST_DB_HOST - -e DF_TEST_DB_DATABASE - -e DF_TEST_DB_USERNAME - -e DF_TEST_DB_PASSWORD - -e OVERSEER_ENABLED - -e DF_ENCRYPTION_PRIMARY_KEY - -e DF_ENCRYPTION_DETERMINISTIC_KEY - -e DF_ENCRYPTION_KEY_DERIVATION_SALT - -e DF_REDIS_SIDEKIQ_URL - -e LATEX_CONTAINER_NAME - -e LATEX_BUILD_PATH - -e LTI_SHARED_API_SECRET - -e LTI_ENABLED - run: TERM=xterm bundle exec rails test - - name: Stop TexLive service - run: docker rm -f ${{ env.LATEX_CONTAINER_NAME }} - - name: Stop JPlag service - run: docker rm -f jplag + name: unit-test-shard-evidence-${{ matrix.worker }} + path: | + tmp/test-shard-manifests/ + tmp/test-shard-run-counts/ + tmp/test-shard-executed-runnables/ + tmp/test-selector-inventory.txt + tmp/test-runnable-inventory.txt + if-no-files-found: error + + unit-tests: + name: unit-tests + if: ${{ always() }} + needs: unit_test_shards + runs-on: ubuntu-latest + steps: + - name: Download test shard manifests + id: download_manifests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: unit-test-shard-evidence-* + path: tmp/all-test-shard-manifests + merge-multiple: true + - name: Verify exact test shard union + id: verify_manifests + run: | + manifest_dir=tmp/all-test-shard-manifests/test-shard-manifests + run_count_dir=tmp/all-test-shard-manifests/test-shard-run-counts + executed_runnables_dir=tmp/all-test-shard-manifests/test-shard-executed-runnables + selector_inventory_path=tmp/all-test-shard-manifests/test-selector-inventory.txt + inventory_path=tmp/all-test-shard-manifests/test-runnable-inventory.txt + + manifest_count=$(find "$manifest_dir" -type f -name 'shard-*.txt' | wc -l) + if [ "$manifest_count" -ne 20 ]; then + echo "::error::Expected 20 shard manifests, found $manifest_count." + exit 1 + fi + + if [ ! -s "$selector_inventory_path" ]; then + echo "::error::The canonical test selector inventory is missing." + exit 1 + fi + LC_ALL=C sort "$selector_inventory_path" > expected-tests.txt + cat "$manifest_dir"/shard-*.txt | LC_ALL=C sort > assigned-tests.txt + LC_ALL=C uniq -d assigned-tests.txt > duplicate-tests.txt + + if [ -s duplicate-tests.txt ]; then + echo "::error::One or more test runnables were assigned to multiple shards." + cat duplicate-tests.txt + exit 1 + fi + + LC_ALL=C uniq assigned-tests.txt > assigned-tests-unique.txt + diff -u expected-tests.txt assigned-tests-unique.txt + + run_count_file_count=$(find "$run_count_dir" -type f -name 'shard-*.txt' | wc -l) + if [ "$run_count_file_count" -ne 20 ]; then + echo "::error::Expected 20 shard run-count files, found $run_count_file_count." + exit 1 + fi + if [ ! -s "$inventory_path" ]; then + echo "::error::The canonical Minitest runnable inventory is missing." + exit 1 + fi + for run_count_path in "$run_count_dir"/shard-*.txt; do + if ! grep -Eq '^[0-9]+$' "$run_count_path"; then + echo "::error::Invalid shard run count in $run_count_path." + exit 1 + fi + done + + expected_run_count=$(wc -l < "$inventory_path") + actual_run_count=$(awk '{ total += $1 } END { print total + 0 }' "$run_count_dir"/shard-*.txt) + if [ "$actual_run_count" -ne "$expected_run_count" ]; then + echo "::error::Shards executed $actual_run_count tests, expected $expected_run_count." + exit 1 + fi + + executed_runnables_file_count=$(find "$executed_runnables_dir" -type f -name 'shard-*.txt' | wc -l) + if [ "$executed_runnables_file_count" -ne 20 ]; then + echo "::error::Expected 20 executed-runnable files, found $executed_runnables_file_count." + exit 1 + fi + cat "$executed_runnables_dir"/shard-*.txt | LC_ALL=C sort > actual-executed-runnables.txt + LC_ALL=C uniq -d actual-executed-runnables.txt > duplicate-executed-runnables.txt + if [ -s duplicate-executed-runnables.txt ]; then + echo "::error::One or more Minitest runnables executed more than once." + cat duplicate-executed-runnables.txt + exit 1 + fi + LC_ALL=C sort "$inventory_path" > expected-executed-runnables.txt + diff -u expected-executed-runnables.txt actual-executed-runnables.txt + echo "Verified exact execution parity for $actual_run_count Minitest runnables." + - name: Confirm all unit test shards passed + if: ${{ always() }} + env: + SHARD_RESULT: ${{ needs.unit_test_shards.result }} + MANIFEST_DOWNLOAD_RESULT: ${{ steps.download_manifests.outcome }} + MANIFEST_VERIFY_RESULT: ${{ steps.verify_manifests.outcome }} + run: | + if [ "$MANIFEST_DOWNLOAD_RESULT" != "success" ] || [ "$MANIFEST_VERIFY_RESULT" != "success" ]; then + echo "::error::Test shard manifest verification did not succeed "\ + "(download: $MANIFEST_DOWNLOAD_RESULT, verify: $MANIFEST_VERIFY_RESULT)." + exit 1 + fi + if [ "$SHARD_RESULT" != "success" ]; then + echo "::error::One or more unit test shards did not succeed (result: $SHARD_RESULT)." + exit 1 + fi diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index d733ac4c9c..cd489c76b0 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -5,10 +5,9 @@ on: paths-ignore: - "*.md" - "docs/**" - pull_request: - paths-ignore: - - "*.md" - - "docs/**" + # This check is required by the repository ruleset, so it must report for + # every pull request, including documentation-only changes. + pull_request: {} permissions: contents: read @@ -20,10 +19,10 @@ jobs: BUNDLE_WITHOUT: default doc job cable storage ujs test db steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Ruby 3.4 - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: 3.4 bundler-cache: true diff --git a/.gitignore b/.gitignore index 35a46c3ae7..56c170e4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,14 +4,18 @@ # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile ~/.gitignore_global +# Ignore Git's own internal plumbing file +FETCH_HEAD + # Ignore bundler config /.bundle # Ignore locally installed gems /vendor/bundle/ -# Ignore the bin folder made with the app:update:bin task -/bin +# Ignore generated binstubs except the Rails launcher required by Rails/Puma restart. +/bin/* +!/bin/rails # Ignore the default SQLite database. /db/*.sqlite3 @@ -31,6 +35,7 @@ student-work/ .DS_Store .env .env* +!.env.example /config/credentials/*.yml.enc /config/credentials/*.key /config/master.key @@ -43,4 +48,4 @@ _history # Institution specific config config/*_setting.rb -!config/no_institution_setting.rb +!config/no_institution_setting.rb \ No newline at end of file diff --git a/.overcommit.yml b/.overcommit.yml deleted file mode 100644 index 3267274320..0000000000 --- a/.overcommit.yml +++ /dev/null @@ -1,22 +0,0 @@ -PreCommit: - OvercommitConfig: - enabled: true - description: Check lab hooks are configured - -CommitMsg: - CommitTag: - enabled: true - description: Check subject tag - Imperative: - enabled: true - description: Check subject tense - CapitalizedSubject: - enabled: false - SubjectFormat: - enabled: true - description: Check subject format - -PrePush: - BranchFormat: - enabled: true - description: Check branch naming diff --git a/.rspec b/.rspec deleted file mode 100644 index 83e16f8044..0000000000 --- a/.rspec +++ /dev/null @@ -1,2 +0,0 @@ ---color ---require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml index 465365ef41..b52e1d00aa 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,15 +5,14 @@ AllCops: - db/**/* - vendor/**/* - spec/**/* - - test/**/* TargetRubyVersion: 3.1 NewCops: disable plugins: - rubocop-rails # - rubocop-performance - # - rubocop-minitest - # - rubocop-factory_bot + - rubocop-minitest + - rubocop-factory_bot Style/HashSyntax: EnforcedShorthandSyntax: never diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 6268621a0f..14a7431746 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,28 +1,26 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2022-12-29 09:30:33 UTC using RuboCop version 1.41.1. +# on 2026-09-21 08:58:15 UTC using RuboCop version 1.75.1. +# Reviewed to preserve existing production rules and scope new test debt to files. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 2 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, IndentationWidth. -# SupportedStyles: outdent, indent -Layout/AccessModifierIndentation: - Exclude: - - 'app/models/unit.rb' - -# Offense count: 10 +# Offense count: 14 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: with_first_argument, with_fixed_indentation Layout/ArgumentAlignment: Exclude: - - 'app/api/activity_types_authenticated_api.rb' - - 'app/api/authentication_api.rb' - - 'app/api/campuses_authenticated_api.rb' + - 'test/api/api_root_test.rb' + - 'test/api/feedback/feedback_chip_authorization_test.rb' + - 'test/api/scorm_api_test.rb' + - 'test/api/submission_history_access_test.rb' + - 'test/api/tii/tii_group_attachment_api_test.rb' + - 'test/models/task_test.rb' + - 'test/models/unit_learning_session_test.rb' + - 'test/models/unit_model_test.rb' # Offense count: 3 # This cop supports safe autocorrection (--autocorrect). @@ -32,212 +30,170 @@ Layout/ArrayAlignment: Exclude: - 'app/helpers/file_helper.rb' -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: IndentationWidth. -Layout/AssignmentIndentation: - Exclude: - - 'lib/tasks/init.rake' - -# Offense count: 2 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyleAlignWith. -# SupportedStylesAlignWith: either, start_of_block, start_of_line -Layout/BlockAlignment: - Exclude: - - 'app/models/project.rb' - - 'config/deakin.rb' - -# Offense count: 19 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, IndentOneStep, IndentationWidth. -# SupportedStyles: case, end -Layout/CaseIndentation: - Exclude: - - 'app/models/task_status.rb' - - 'app/models/webcal.rb' - - 'config/deakin.rb' - -# Offense count: 4 +# Offense count: 6 # This cop supports safe autocorrection (--autocorrect). Layout/ClosingParenthesisIndentation: Exclude: - 'app/api/units_api.rb' - 'app/models/unit.rb' - - 'config/deakin.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/helpers/tii_test_helper.rb' + - 'test/models/task_similarity_test.rb' + - 'test/models/tii_model_test.rb' -# Offense count: 2 +# Offense count: 5 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowForAlignment. Layout/CommentIndentation: Exclude: - - 'config/initializers/inflections.rb' + - 'test/api/activity_types_api_test.rb' + - 'test/api/csv_test.rb' + - 'test/api/groups_api_test.rb' -# Offense count: 76 +# Offense count: 107 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: leading, trailing Layout/DotPosition: Exclude: - - 'app/api/group_sets_api.rb' - - 'app/api/task_definitions_api.rb' - - 'app/api/tasks_api.rb' - - 'app/models/project.rb' - - 'app/models/task.rb' - - 'app/models/tutorial_enrolment.rb' - 'app/models/unit.rb' - - 'app/models/unit_role.rb' - - 'config/deakin.rb' - - 'lib/tasks/maintenance.rake' - -# Offense count: 3 -# This cop supports safe autocorrection (--autocorrect). -Layout/ElseAlignment: - Exclude: - - 'app/api/students_api.rb' - - 'app/models/task_definition.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/models/task_similarity_test.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/tii_model_test.rb' + - 'test/models/tii_user_accept_eula_test.rb' + - 'test/sidekiq/tii_check_progress_job_test.rb' + - 'test/sidekiq/tii_webhooks_job_test.rb' -# Offense count: 67 +# Offense count: 54 # This cop supports safe autocorrection (--autocorrect). Layout/EmptyLineAfterGuardClause: Enabled: false -# Offense count: 2 -# This cop supports safe autocorrection (--autocorrect). -Layout/EmptyLineAfterMagicComment: - Exclude: - - 'app/api/discussion_comment_api.rb' - - 'app/models/comments/task_comment.rb' - -# Offense count: 4 +# Offense count: 5 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EmptyLineBetweenMethodDefs, EmptyLineBetweenClassDefs, EmptyLineBetweenModuleDefs, AllowAdjacentOneLineDefs, NumberOfEmptyLines. +# Configuration parameters: EmptyLineBetweenMethodDefs, EmptyLineBetweenClassDefs, EmptyLineBetweenModuleDefs, DefLikeMacros, AllowAdjacentOneLineDefs, NumberOfEmptyLines. Layout/EmptyLineBetweenDefs: Exclude: - 'app/models/overseer_assessment.rb' - - 'app/models/role.rb' - 'app/models/unit.rb' + - 'test/api/units_api_test.rb' -# Offense count: 13 +# Offense count: 21 # This cop supports safe autocorrection (--autocorrect). Layout/EmptyLines: Exclude: - 'app/api/submission/portfolio_evidence_api.rb' - - 'app/helpers/csv_helper.rb' - - 'app/models/auth_token.rb' - 'app/models/overseer_assessment.rb' - - 'app/models/role.rb' - 'app/models/unit.rb' - - 'config/environments/development.rb' - - 'lib/tasks/init.rake' + - 'test/api/comments/extension_test.rb' + - 'test/api/csv_test.rb' + - 'test/api/feedback/feedback_chip_api_consolidated_test.rb' + - 'test/api/units_api_test.rb' + - 'test/models/teaching_period_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 9 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: around, only_before -Layout/EmptyLinesAroundAccessModifier: - Exclude: - - 'app/models/activity_type.rb' - - 'app/models/campus.rb' - - 'app/models/project.rb' - - 'app/models/task.rb' - - 'app/models/tutorial.rb' - - 'app/models/tutorial_enrolment.rb' - - 'app/models/tutorial_stream.rb' - - 'app/models/unit.rb' - -# Offense count: 6 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: empty_lines, no_empty_lines Layout/EmptyLinesAroundBlockBody: Exclude: - - 'app/api/submission/portfolio_evidence_api.rb' - - 'app/api/webcal_public_api.rb' - - 'app/models/tutorial_enrolment.rb' - - 'config/environments/production.rb' - - 'lib/tasks/init.rake' - - 'lib/tasks/send_status_emails.rake' + - 'test/factories/units_factory.rb' + - 'test/models/unit_model_test.rb' + - 'test/models/webcal_test.rb' -# Offense count: 22 +# Offense count: 55 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only Layout/EmptyLinesAroundClassBody: Enabled: false -# Offense count: 5 +# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). Layout/EmptyLinesAroundExceptionHandlingKeywords: Exclude: - - 'app/helpers/file_helper.rb' - - 'app/models/overseer_assessment.rb' - 'app/models/task.rb' - - 'app/models/unit.rb' - - 'lib/assets/ontrack_receive_action.rb' + - 'test/models/task_similarity_test.rb' -# Offense count: 2 +# Offense count: 52 # This cop supports safe autocorrection (--autocorrect). Layout/EmptyLinesAroundMethodBody: Exclude: - - 'app/models/portfolio_evidence.rb' - 'app/models/unit_role.rb' + - 'test/api/csv_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/unit_roles_test.rb' + - 'test/api/units_api_test.rb' + - 'test/api/users_test.rb' + - 'test/models/teaching_period_test.rb' -# Offense count: 2 +# Offense count: 5 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines Layout/EmptyLinesAroundModuleBody: Exclude: - - 'app/api/admin/overseer_admin_api.rb' - - 'app/helpers/authentication_helpers.rb' + - 'test/helpers/account_helper.rb' + - 'test/helpers/test_file_helper.rb' -# Offense count: 5 +# Offense count: 13 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyleAlignWith, Severity. -# SupportedStylesAlignWith: keyword, variable, start_of_line -Layout/EndAlignment: +# Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment. +Layout/ExtraSpacing: Exclude: - - 'app/api/students_api.rb' - - 'app/channels/application_cable/channel.rb' - - 'app/models/task_definition.rb' - - 'config/application.rb' + - 'test/api/api_root_test.rb' + - 'test/api/breaks_api_test.rb' + - 'test/factories/discussion_comments.rb' + - 'test/factories/groups_factory.rb' + - 'test/factories/units_factory.rb' + - 'test/factories/users_factory.rb' + - 'test/models/task_similarity_test.rb' -# Offense count: 21 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment. -Layout/ExtraSpacing: +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: consistent, consistent_relative_to_receiver, special_for_inner_method_call, special_for_inner_method_call_in_parentheses +Layout/FirstArgumentIndentation: Exclude: - - 'app/api/entities/project_entity.rb' - - 'app/api/entities/unit_entity.rb' - - 'app/api/projects_api.rb' - - 'app/api/tutorial_streams_api.rb' - - 'app/helpers/mime_check_helpers.rb' - - 'app/mailers/notifications_mailer.rb' - - 'app/models/project.rb' - - 'app/models/task.rb' - - 'config/initializers/swagger.rb' - - 'lib/helpers/database_populator.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/helpers/tii_test_helper.rb' -# Offense count: 2 +# Offense count: 9 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_brackets Layout/FirstArrayElementIndentation: Exclude: - - 'app/models/unit.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/helpers/tii_test_helper.rb' + - 'test/models/tii_model_test.rb' + - 'test/sidekiq/tii_check_progress_job_test.rb' -# Offense count: 4 +# Offense count: 90 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_braces Layout/FirstHashElementIndentation: Exclude: - 'app/models/unit.rb' - - 'config/no_institution_setting.rb' - - 'lib/helpers/database_populator.rb' + - 'test/api/comments/extension_test.rb' + - 'test/api/comments/status_test.rb' + - 'test/api/csv_test.rb' + - 'test/api/groups_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/api/users_test.rb' + - 'test/helpers/tii_test_helper.rb' + - 'test/models/group_test.rb' + - 'test/models/project_model_test.rb' + - 'test/models/task_definition_test.rb' + - 'test/models/task_test.rb' + - 'test/models/teaching_period_test.rb' + - 'test/services/teams_graph_client_test.rb' -# Offense count: 114 +# Offense count: 17 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. # SupportedHashRocketStyles: key, separator, table @@ -245,233 +201,173 @@ Layout/FirstHashElementIndentation: # SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit Layout/HashAlignment: Exclude: - - 'app/api/authentication_api.rb' - - 'app/api/settings_api.rb' - - 'app/models/task.rb' - - 'app/models/teaching_period.rb' - - 'app/models/unit.rb' - - 'app/models/user.rb' - - 'config/deakin.rb' - - 'config/environments/production.rb' - - 'config/no_institution_setting.rb' - - 'lib/helpers/database_populator.rb' - - 'lib/helpers/find_or_create_students.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/resubmission_setting_test.rb' + - 'test/api/submission_history_access_test.rb' + - 'test/api/units_api_test.rb' + - 'test/helpers/tii_test_helper.rb' + - 'test/models/submission_lifecycle_test.rb' + - 'test/models/unit_learning_session_test.rb' + - 'test/sidekiq/communication_recipient_scope_test.rb' -# Offense count: 9 +# Offense count: 8 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: normal, indented_internal_methods Layout/IndentationConsistency: Exclude: - - 'app/models/task.rb' - - 'app/models/task_definition.rb' - - 'app/models/tutorial_enrolment.rb' - - 'config/deakin.rb' + - 'test/api/activity_types_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/models/group_set_test.rb' -# Offense count: 36 +# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: Width, AllowedPatterns, IgnoredPatterns. -Layout/IndentationWidth: +# Configuration parameters: IndentationWidth, EnforcedStyle. +# SupportedStyles: spaces, tabs +Layout/IndentationStyle: Exclude: - - 'app/api/students_api.rb' - - 'app/channels/application_cable/channel.rb' - - 'app/mailers/notifications_mailer.rb' - - 'app/models/task.rb' - - 'app/models/task_definition.rb' - - 'app/models/tutorial_enrolment.rb' - - 'app/models/unit.rb' - - 'app/models/user.rb' - - 'config/application.rb' - - 'config/deakin.rb' - - 'config/initializers/inflections.rb' - - 'config/no_institution_setting.rb' - - 'lib/tasks/send_status_emails.rake' - - 'lib/tasks/skip_prod.rake' + - 'test/models/group_set_test.rb' -# Offense count: 38 +# Offense count: 12 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowDoxygenCommentStyle, AllowGemfileRubyComment. -Layout/LeadingCommentSpace: +# Configuration parameters: Width, AllowedPatterns. +Layout/IndentationWidth: Exclude: - - 'app/api/entities/group_entity.rb' - - 'app/api/entities/tutorial_entity.rb' - - 'app/api/entities/tutorial_stream_entity.rb' - - 'app/api/task_definitions_api.rb' - - 'app/models/overseer_assessment.rb' - - 'app/models/project.rb' - 'app/models/task.rb' - 'app/models/task_definition.rb' - - 'app/models/task_status.rb' - - 'app/models/teaching_period.rb' - 'app/models/unit.rb' - - 'config/deakin.rb' - - 'lib/helpers/database_populator.rb' + - 'test/api/groups_api_test.rb' + - 'test/api/units_api_test.rb' -# Offense count: 5 +# Offense count: 135 # This cop supports safe autocorrection (--autocorrect). -Layout/LeadingEmptyLines: - Exclude: - - 'app/api/entities/minimal/minimal_unit_entity.rb' - - 'app/api/entities/minimal/minimal_user_entity.rb' - - 'app/api/entities/user_entity.rb' - - 'app/api/entities/webcal_entity.rb' - - 'config/no_institution_setting.rb' - -# Offense count: 16 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: AutoCorrect, EnforcedStyle. -# SupportedStyles: leading, trailing -Layout/LineContinuationLeadingSpace: - Exclude: - - 'config/application.rb' - -# Offense count: 16 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AutoCorrect, EnforcedStyle. -# SupportedStyles: space, no_space -Layout/LineContinuationSpacing: - Exclude: - - 'config/application.rb' - -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, IndentationWidth. -# SupportedStyles: aligned, indented -Layout/LineEndStringConcatenationIndentation: +# Configuration parameters: AllowDoxygenCommentStyle, AllowGemfileRubyComment, AllowRBSInlineAnnotation, AllowSteepAnnotation. +Layout/LeadingCommentSpace: Exclude: - - 'config/application.rb' + - 'test/api/activity_types_api_test.rb' + - 'test/api/breaks_api_test.rb' + - 'test/api/campuses_test.rb' + - 'test/api/csv_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/teaching_period_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/factories/units_factory.rb' + - 'test/models/group_test.rb' + - 'test/models/task_pin_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 4 +# Offense count: 7 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: symmetrical, new_line, same_line Layout/MultilineMethodCallBraceLayout: Exclude: - 'app/api/units_api.rb' - - 'app/models/unit.rb' - - 'app/models/unit_role.rb' + - 'test/api/scorm_api_test.rb' + - 'test/api/tii/tii_group_attachment_api_test.rb' + - 'test/models/task_similarity_test.rb' + - 'test/models/tii_model_test.rb' + - 'test/sidekiq/tii_webhooks_job_test.rb' -# Offense count: 82 +# Offense count: 174 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: aligned, indented, indented_relative_to_receiver Layout/MultilineMethodCallIndentation: Exclude: - - 'app/api/activity_types_authenticated_api.rb' - - 'app/api/admin/overseer_admin_api.rb' - - 'app/api/campuses_authenticated_api.rb' - - 'app/api/group_sets_api.rb' - - 'app/api/learning_outcomes_api.rb' - - 'app/api/task_definitions_api.rb' - - 'app/api/tasks_api.rb' - - 'app/api/teaching_periods_authenticated_api.rb' - 'app/api/unit_roles_api.rb' - - 'app/api/webcal_api.rb' - 'app/models/project.rb' - - 'app/models/tutorial_enrolment.rb' - 'app/models/unit.rb' - 'app/models/unit_role.rb' - - 'config/deakin.rb' - -# Offense count: 11 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, IndentationWidth. -# SupportedStyles: aligned, indented -Layout/MultilineOperationIndentation: - Exclude: - - 'app/api/authentication_api.rb' - - 'app/models/unit.rb' - - 'config/application.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/models/task_similarity_test.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/tii_model_test.rb' + - 'test/models/tii_user_accept_eula_test.rb' + - 'test/services/teams_graph_client_test.rb' + - 'test/sidekiq/tii_check_progress_job_test.rb' + - 'test/sidekiq/tii_webhooks_job_test.rb' -# Offense count: 4 +# Offense count: 55 # This cop supports safe autocorrection (--autocorrect). Layout/SpaceAfterColon: Exclude: - - 'app/api/extension_comments_api.rb' - - 'config/deakin.rb' + - 'test/api/activity_types_api_test.rb' + - 'test/api/comments/extension_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/factories/projects_factory.rb' + - 'test/models/project_model_test.rb' + - 'test/models/task_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 46 +# Offense count: 86 # This cop supports safe autocorrection (--autocorrect). Layout/SpaceAfterComma: Exclude: - - 'app/api/api_root.rb' - - 'app/api/tutorial_streams_api.rb' - - 'app/api/units_api.rb' - - 'app/helpers/application_helper.rb' - 'app/helpers/file_helper.rb' - - 'app/models/activity_type.rb' - - 'app/models/campus.rb' - - 'app/models/task.rb' - - 'app/models/teaching_period.rb' - - 'config/deakin.rb' - - 'lib/helpers/database_populator.rb' + - 'test/api/csv_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/students_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/api/users_test.rb' + - 'test/factories/units_factory.rb' + - 'test/models/group_set_test.rb' + - 'test/models/group_test.rb' + - 'test/models/task_definition_test.rb' + - 'test/models/task_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 7 -# This cop supports safe autocorrection (--autocorrect). -Layout/SpaceAfterMethodName: - Exclude: - - 'app/models/project.rb' - - 'app/models/user.rb' - - 'config/deakin.rb' - - 'config/no_institution_setting.rb' - -# Offense count: 14 +# Offense count: 4 # This cop supports safe autocorrection (--autocorrect). Layout/SpaceAfterNot: Exclude: - - 'app/api/group_sets_api.rb' - - 'app/api/tutorial_enrolments_api.rb' - - 'app/models/comments/extension_comment.rb' - - 'app/models/comments/task_comment.rb' - - 'app/models/group.rb' - - 'app/models/project.rb' - - 'app/models/task.rb' - - 'app/models/tutorial.rb' - - 'app/models/tutorial_enrolment.rb' - 'app/models/unit.rb' - - 'config/deakin.rb' -# Offense count: 22 +# Offense count: 20 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyleInsidePipes. # SupportedStylesInsidePipes: space, no_space Layout/SpaceAroundBlockParameters: Exclude: - - 'app/api/entities/project_entity.rb' - - 'app/models/task.rb' - - 'app/models/webcal.rb' - - 'lib/helpers/database_populator.rb' - - 'lib/tasks/init.rake' + - 'test/api/activity_types_api_test.rb' + - 'test/api/breaks_api_test.rb' + - 'test/api/campuses_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/students_api_test.rb' + - 'test/api/teaching_period_api_test.rb' + - 'test/api/users_test.rb' + - 'test/factories/units_factory.rb' + - 'test/helpers/test_file_helper.rb' -# Offense count: 6 +# Offense count: 4 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: space, no_space Layout/SpaceAroundEqualsInParameterDefault: Exclude: - - 'app/models/teaching_period.rb' - - 'app/models/unit.rb' - - 'lib/helpers/faker_randomiser.rb' + - 'test/api/units_api_test.rb' + - 'test/api/users_test.rb' -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Layout/SpaceAroundMethodCallOperator: - Exclude: - - 'app/models/unit.rb' - -# Offense count: 7 +# Offense count: 17 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator. +# Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator, EnforcedStyleForRationalLiterals. # SupportedStylesForExponentOperator: space, no_space +# SupportedStylesForRationalLiterals: space, no_space Layout/SpaceAroundOperators: Exclude: - - 'app/api/submission/portfolio_evidence_api.rb' - - 'app/models/task.rb' - - 'config/deakin.rb' - - 'config/initializers/swagger.rb' - 'lib/helpers/database_populator.rb' + - 'test/api/activity_types_api_test.rb' + - 'test/api/breaks_api_test.rb' + - 'test/api/teaching_period_api_test.rb' + - 'test/api/unit_roles_test.rb' + - 'test/api/units_api_test.rb' + - 'test/helpers/tii_test_helper.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/tii_model_test.rb' -# Offense count: 12 +# Offense count: 9 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. # SupportedStyles: space, no_space @@ -479,125 +375,99 @@ Layout/SpaceAroundOperators: Layout/SpaceBeforeBlockBraces: Exclude: - 'app/helpers/file_helper.rb' - - 'app/models/activity_type.rb' - - 'app/models/campus.rb' - - 'app/models/project.rb' - 'app/models/task_definition.rb' - - 'app/models/teaching_period.rb' - - 'app/models/unit.rb' - - 'app/models/webcal.rb' - -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Layout/SpaceBeforeBrackets: - Exclude: - - 'app/models/unit.rb' + - 'test/api/auth_test.rb' + - 'test/models/feedback/learning_outcome_model_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 118 +# Offense count: 56 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBrackets. # SupportedStyles: space, no_space, compact # SupportedStylesForEmptyBrackets: space, no_space Layout/SpaceInsideArrayLiteralBrackets: Exclude: - - 'app/api/projects_api.rb' - - 'app/api/units_api.rb' - - 'app/api/users_api.rb' - 'app/helpers/file_helper.rb' - - 'app/models/group.rb' - - 'app/models/group_set.rb' - - 'app/models/project.rb' - - 'app/models/task.rb' - - 'app/models/task_definition.rb' - - 'app/models/unit.rb' - - 'app/models/unit_role.rb' - - 'app/models/user.rb' - - 'config/deakin.rb' - - 'config/initializers/devise.rb' - - 'lib/helpers/database_populator.rb' - -# Offense count: 34 + - 'test/api/comments/comment_test.rb' + - 'test/api/comments/extension_test.rb' + - 'test/api/comments/status_test.rb' + - 'test/api/groups_api_test.rb' + - 'test/helpers/test_file_helper.rb' + - 'test/models/group_test.rb' + - 'test/models/task_definition_test.rb' + - 'test/models/task_test.rb' + - 'test/models/unit_model_test.rb' + +# Offense count: 30 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. # SupportedStyles: space, no_space # SupportedStylesForEmptyBraces: space, no_space Layout/SpaceInsideBlockBraces: Exclude: - - 'app/api/entities/project_entity.rb' - 'app/helpers/file_helper.rb' - - 'app/mailers/notifications_mailer.rb' - - 'app/models/activity_type.rb' - - 'app/models/campus.rb' - - 'app/models/portfolio_evidence.rb' - - 'app/models/project.rb' - - 'app/models/task.rb' - 'app/models/task_definition.rb' - - 'app/models/teaching_period.rb' - - 'app/models/unit.rb' - - 'app/models/webcal.rb' + - 'test/api/auth_test.rb' + - 'test/factories/teaching_period_factory.rb' + - 'test/models/break_test.rb' + - 'test/models/group_test.rb' + - 'test/models/tii_model_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 69 +# Offense count: 173 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces. # SupportedStyles: space, no_space, compact # SupportedStylesForEmptyBraces: space, no_space Layout/SpaceInsideHashLiteralBraces: Exclude: - - 'app/api/api_root.rb' - - 'app/api/extension_comments_api.rb' - - 'app/api/group_sets_api.rb' - - 'app/api/projects_api.rb' - - 'app/api/task_comments_api.rb' - - 'app/api/teaching_periods_authenticated_api.rb' - - 'app/api/units_api.rb' - - 'app/models/overseer_assessment.rb' - - 'app/models/project.rb' - - 'app/models/tutorial_stream.rb' - - 'app/models/unit.rb' - - 'app/models/user.rb' - - 'config/deakin.rb' - - 'lib/helpers/database_populator.rb' + - 'test/api/collection_pagination_test.rb' + - 'test/api/comments/extension_test.rb' + - 'test/api/d2l_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/groups_api_test.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/api/tutorial_stream_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/factories/units_factory.rb' + - 'test/helpers/test_file_helper.rb' + - 'test/models/file_helper_test.rb' + - 'test/models/group_test.rb' + - 'test/models/task_definition_test.rb' + - 'test/models/task_test.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/tii_model_test.rb' + - 'test/models/tii_user_accept_eula_test.rb' + - 'test/models/tutorial_enrolment_model_test.rb' + - 'test/models/unit_model_test.rb' + - 'test/sidekiq/tii_check_progress_job_test.rb' -# Offense count: 27 +# Offense count: 21 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: space, compact, no_space Layout/SpaceInsideParens: Exclude: - - 'app/api/api_root.rb' - - 'app/api/units_api.rb' - - 'app/helpers/file_helper.rb' - - 'app/models/project.rb' - - 'app/models/task_definition.rb' - - 'app/models/tutorial_enrolment.rb' - - 'app/models/unit.rb' - - 'config/deakin.rb' - - 'lib/helpers/database_populator.rb' - - 'lib/tasks/generate_pdfs.rake' + - 'test/api/d2l_test.rb' + - 'test/factories/units_factory.rb' + - 'test/helpers/test_file_helper.rb' + - 'test/models/project_model_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 1 +# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). -Layout/SpaceInsideRangeLiteral: +Layout/SpaceInsidePercentLiteralDelimiters: Exclude: - - 'lib/helpers/database_populator.rb' + - 'test/api/units_api_test.rb' -# Offense count: 1 +# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBrackets. # SupportedStyles: space, no_space # SupportedStylesForEmptyBrackets: space, no_space Layout/SpaceInsideReferenceBrackets: Exclude: - - 'app/models/unit.rb' - -# Offense count: 2 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: space, no_space -Layout/SpaceInsideStringInterpolation: - Exclude: - - 'app/helpers/file_helper.rb' - - 'app/models/tutorial_enrolment.rb' + - 'test/helpers/test_file_helper.rb' # Offense count: 4 # This cop supports safe autocorrection (--autocorrect). @@ -605,44 +475,51 @@ Layout/SpaceInsideStringInterpolation: # SupportedStyles: final_newline, final_blank_line Layout/TrailingEmptyLines: Exclude: - - 'app/channels/application_cable/channel.rb' - - 'config/initializers/swagger.rb' - - 'lib/helpers/faker_randomiser.rb' - - 'lib/tasks/send_status_emails.rake' + - 'test/api/submission/portfolio_api_test.rb' + - 'test/api/submission_access_test.rb' + - 'test/dx_a02_test.rb' + - 'test/helpers/account_helper.rb' -# Offense count: 10 +# Offense count: 46 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowInHeredoc. Layout/TrailingWhitespace: Exclude: - - 'app/mailers/convenor_contact_mailer.rb' - - 'app/mailers/portfolio_evidence_mailer.rb' - - 'app/models/portfolio_evidence.rb' - - 'config/deakin.rb' - - 'lib/tasks/send_status_emails.rake' + - 'test/api/activity_types_api_test.rb' + - 'test/api/teaching_period_api_test.rb' + - 'test/factories/moderated_tasks.rb' + - 'test/factories/overseer_steps.rb' + - 'test/factories/task_definition_grade_due_dates.rb' + - 'test/factories/teaching_period_factory.rb' + - 'test/factories/tutor_feedback_scores.rb' + - 'test/models/group_set_test.rb' -# Offense count: 1 -# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods. +# Offense count: 24 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowedMethods, AllowedPatterns. Lint/AmbiguousBlockAssociation: Exclude: - 'app/models/task.rb' + - 'test/api/collection_pagination_test.rb' + - 'test/api/submission_history_access_test.rb' + - 'test/api/unit_hub_api_test.rb' + - 'test/lib/demo_data/all_features_scenario_test.rb' + - 'test/models/group_test.rb' + - 'test/models/unit_learning_session_test.rb' + - 'test/sidekiq/communication_recipient_scope_test.rb' + - 'test/sidekiq/execute_communication_set_job_test.rb' -# Offense count: 3 +# Offense count: 1 # This cop supports safe autocorrection (--autocorrect). Lint/AmbiguousOperator: Exclude: - - 'app/helpers/file_helper.rb' - 'app/models/portfolio_evidence.rb' - - 'app/models/task.rb' -# Offense count: 14 +# Offense count: 1 # This cop supports safe autocorrection (--autocorrect). Lint/AmbiguousOperatorPrecedence: Exclude: - - 'app/models/project.rb' - - 'app/models/task.rb' - - 'app/models/unit.rb' - - 'lib/tasks/populate.rake' + - 'test/factories/teaching_period_factory.rb' # Offense count: 7 # This cop supports safe autocorrection (--autocorrect). @@ -655,27 +532,23 @@ Lint/AmbiguousRegexpLiteral: - 'app/helpers/file_helper.rb' # Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). +# This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: AllowSafeAssignment. Lint/AssignmentInCondition: Exclude: - 'app/channels/application_cable/connection.rb' -# Offense count: 2 -# This cop supports safe autocorrection (--autocorrect). -Lint/DeprecatedClassMethods: - Exclude: - - 'app/models/task.rb' - -# Offense count: 24 -# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches. +# Offense count: 1 +# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch. Lint/DuplicateBranch: Exclude: - - 'app/api/api_root.rb' - 'app/helpers/file_helper.rb' - - 'app/models/project.rb' - - 'app/models/task_status.rb' - - 'lib/tasks/populate.rake' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Lint/DuplicateRequire: + Exclude: + - 'test/test_helper.rb' # Offense count: 2 # This cop supports safe autocorrection (--autocorrect). @@ -684,22 +557,30 @@ Lint/ElseLayout: - 'app/models/project.rb' - 'app/models/task.rb' -# Offense count: 1 +# Offense count: 8 # Configuration parameters: AllowComments, AllowEmptyLambdas. Lint/EmptyBlock: Exclude: - - 'app/helpers/file_helper.rb' + - 'test/factories/moderated_tasks.rb' + - 'test/factories/overseer_steps.rb' + - 'test/factories/staff_notes.rb' + - 'test/factories/task_definition_grade_due_dates.rb' + - 'test/factories/task_prerequisites.rb' + - 'test/factories/tutor_feedback_scores.rb' + - 'test/factories/tutor_notes.rb' + - 'test/sidekiq/import_students_lti_job_test.rb' # Offense count: 1 Lint/FloatComparison: Exclude: - 'app/models/project.rb' -# Offense count: 8 +# Offense count: 14 +# This cop supports safe autocorrection (--autocorrect). Lint/ImplicitStringConcatenation: Exclude: - - 'app/api/learning_alignment_api.rb' - 'app/api/learning_outcomes_api.rb' + - 'test/models/tii_model_test.rb' # Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). @@ -709,6 +590,7 @@ Lint/Loop: - 'config/no_institution_setting.rb' # Offense count: 4 +# Configuration parameters: AllowedParentClasses. Lint/MissingSuper: Exclude: - 'app/controllers/lecture_resource_downloads_controller.rb' @@ -716,38 +598,33 @@ Lint/MissingSuper: - 'app/controllers/task_downloads_controller.rb' - 'app/controllers/task_submission_pdfs_controller.rb' -# Offense count: 18 +# Offense count: 4 # This cop supports unsafe autocorrection (--autocorrect-all). Lint/NonAtomicFileOperation: Exclude: - - 'app/helpers/file_helper.rb' - - 'app/models/comments/task_comment.rb' - - 'app/models/project.rb' - - 'app/models/task.rb' + - 'test/helpers/tii_test_helper.rb' # Offense count: 1 Lint/NonLocalExitFromIterator: Exclude: - 'config/deakin.rb' -# Offense count: 1 +# Offense count: 5 # This cop supports safe autocorrection (--autocorrect). Lint/ParenthesesAsGroupedExpression: Exclude: - 'app/models/unit.rb' + - 'test/api/activity_types_api_test.rb' + - 'test/api/overseer_image_api_test.rb' + - 'test/api/teaching_period_api_test.rb' -# Offense count: 5 +# Offense count: 4 # This cop supports safe autocorrection (--autocorrect). Lint/RedundantStringCoercion: Exclude: - 'app/helpers/file_helper.rb' -# Offense count: 2 -Lint/RequireParentheses: - Exclude: - - 'config/application.rb' - -# Offense count: 15 +# Offense count: 16 Lint/RescueException: Exclude: - 'app/models/portfolio_evidence.rb' @@ -756,34 +633,34 @@ Lint/RescueException: - 'config/deakin.rb' - 'lib/tasks/generate_pdfs.rake' -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Lint/ScriptPermission: - Exclude: - - 'Rakefile' - -# Offense count: 8 +# Offense count: 5 Lint/ShadowingOuterLocalVariable: Exclude: - - 'app/models/learning_outcome.rb' - - 'app/models/teaching_period.rb' - 'app/models/unit.rb' + - 'test/factories/units_factory.rb' -# Offense count: 4 +# Offense count: 1 # Configuration parameters: AllowComments, AllowNil. Lint/SuppressedException: Exclude: - - 'app/models/project.rb' - 'app/models/task.rb' - - 'app/models/task_definition.rb' -# Offense count: 5 +# Offense count: 14 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: strict, consistent Lint/SymbolConversion: Exclude: - - 'lib/tasks/init.rake' + - 'test/api/tii/tii_action_api_test.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/api/units/task_definitions_api_test.rb' + - 'test/helpers/test_file_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Lint/TripleQuotes: + Exclude: + - 'test/models/tii_model_test.rb' # Offense count: 2 # Configuration parameters: AllowKeywordBlockArguments. @@ -791,9 +668,9 @@ Lint/UnderscorePrefixedVariableName: Exclude: - 'app/models/comments/discussion_comment.rb' -# Offense count: 33 +# Offense count: 57 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. +# Configuration parameters: AutoCorrect, IgnoreEmptyBlocks, AllowUnusedKeywordArguments. Lint/UnusedBlockArgument: Exclude: - 'app/api/entities/comment_entity.rb' @@ -810,10 +687,16 @@ Lint/UnusedBlockArgument: - 'app/models/unit.rb' - 'lib/helpers/database_populator.rb' - 'lib/tasks/checks.rake' + - 'test/application_system_test_case.rb' + - 'test/factories/groups_factory.rb' + - 'test/factories/units_factory.rb' + - 'test/factories/users_factory.rb' + - 'test/models/webcal_test.rb' -# Offense count: 7 +# Offense count: 9 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods. +# Configuration parameters: AutoCorrect, AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. +# NotImplementedExceptions: NotImplementedError Lint/UnusedMethodArgument: Exclude: - 'app/models/project.rb' @@ -821,51 +704,151 @@ Lint/UnusedMethodArgument: - 'config/deakin.rb' - 'config/no_institution_setting.rb' -# Offense count: 55 +# Offense count: 162 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AutoCorrect. Lint/UselessAssignment: Enabled: false -# Offense count: 145 -# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods, CountRepeatedAttributes. +# Offense count: 1089 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 153 + Exclude: + - 'test/api/d2l_test.rb' + - 'test/api/units/task_definitions_api_test.rb' + - 'test/models/task_status_test.rb' + - 'test/models/task_test.rb' + - 'test/models/tii_model_test.rb' -# Offense count: 65 -# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods. +# Offense count: 186 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. # AllowedMethods: refine Metrics/BlockLength: Max: 200 -# Offense count: 12 -# Configuration parameters: CountBlocks. +# Offense count: 15 +# Configuration parameters: CountBlocks, CountModifierForms. Metrics/BlockNesting: Max: 5 -# Offense count: 65 -# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods. -Metrics/CyclomaticComplexity: - Max: 39 +# Offense count: 184 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 39 + +# Offense count: 1233 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Max: 140 + Exclude: + - 'test/models/tii_model_test.rb' + +# Offense count: 7 +# Configuration parameters: CountComments, CountAsOne. +Metrics/ModuleLength: + Max: 964 + +# Offense count: 14 +# Configuration parameters: CountKeywordArgs. +Metrics/ParameterLists: + Max: 8 + MaxOptionalParameters: 4 + +# Offense count: 153 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/PerceivedComplexity: + Max: 40 + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Minitest/AssertEmpty: + Exclude: + - 'test/api/tasks_api_test.rb' + +# Offense count: 10 +# This cop supports safe autocorrection (--autocorrect). +Minitest/AssertEmptyLiteral: + Exclude: + - 'test/api/submission_history_access_test.rb' + - 'test/api/unit_hub_api_test.rb' + - 'test/api/webcal_api_test.rb' + - 'test/models/task_completion_snapshot_test.rb' + +# Offense count: 21 +# This cop supports safe autocorrection (--autocorrect). +Minitest/AssertEqual: + Exclude: + - 'test/api/auth_test.rb' + - 'test/api/comments/extension_test.rb' + - 'test/api/comments/scorm_extension_test.rb' + - 'test/api/comments/status_test.rb' + - 'test/api/projects_api_test.rb' + - 'test/api/tasks_api_test.rb' + - 'test/api/test_attempts_test.rb' + +# Offense count: 52 +# This cop supports safe autocorrection (--autocorrect). +Minitest/AssertIncludes: + Exclude: + - 'test/api/feedback/feedback_chip_api_consolidated_test.rb' + - 'test/api/lti_api_test.rb' + - 'test/api/tutorials_test.rb' + - 'test/api/units_api_test.rb' + - 'test/helpers/json_helper.rb' + - 'test/mailers/error_log_mailer_test.rb' + - 'test/mailers/tutor_note_mailer_test.rb' + - 'test/mailers/unit_mail_test.rb' + - 'test/models/task_completion_snapshot_test.rb' + - 'test/models/task_test.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/unit_model_test.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +Minitest/AssertNil: + Exclude: + - 'test/api/marking_sessions_api_test.rb' + - 'test/api/tasks_api_test.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 173 -# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, AllowedMethods, AllowedPatterns, IgnoredMethods. -Metrics/MethodLength: - Max: 140 +# Offense count: 54 +# This cop supports unsafe autocorrection (--autocorrect-all). +Minitest/AssertTruthy: + Exclude: + - 'test/api/comments/batch03_docx_attachment_test.rb' + - 'test/api/csv_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/peer_progress_api_test.rb' + - 'test/api/projects_api_test.rb' + - 'test/api/settings_push_test.rb' + - 'test/api/submission/submission_processing_api_test.rb' + - 'test/api/submission_history_access_test.rb' + - 'test/api/test_attempts_test.rb' + - 'test/api/unit_hub_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/api/users_test.rb' + - 'test/lib/demo_data/all_features_scenario_test.rb' + - 'test/models/unit_model_test.rb' + - 'test/services/teams_announcement_sync_test.rb' # Offense count: 1 -# Configuration parameters: CountComments, CountAsOne. -Metrics/ModuleLength: - Enabled: false +# This cop supports safe autocorrection (--autocorrect). +Minitest/RefuteEqual: + Exclude: + - 'test/api/auth_test.rb' -# Offense count: 4 -# Configuration parameters: CountKeywordArgs. -Metrics/ParameterLists: - MaxOptionalParameters: 4 - Max: 8 +# Offense count: 80 +# This cop supports unsafe autocorrection (--autocorrect-all). +Minitest/RefuteFalse: + Enabled: false -# Offense count: 62 -# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods. -Metrics/PerceivedComplexity: - Max: 50 +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +Minitest/RefuteIncludes: + Exclude: + - 'test/api/overseer_steps_api_test.rb' + - 'test/api/units_api_test.rb' # Offense count: 2 Naming/AccessorMethodName: @@ -874,13 +857,14 @@ Naming/AccessorMethodName: - 'app/models/user.rb' # Offense count: 1 -# Configuration parameters: EnforcedStyle, AllowedPatterns, IgnoredPatterns. +# Configuration parameters: EnforcedStyle, AllowedPatterns, ForbiddenIdentifiers, ForbiddenPatterns. # SupportedStyles: snake_case, camelCase +# ForbiddenIdentifiers: __id__, __send__ Naming/MethodName: Exclude: - 'app/models/comments/discussion_comment.rb' -# Offense count: 16 +# Offense count: 13 # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to Naming/MethodParameterName: @@ -892,18 +876,16 @@ Naming/MethodParameterName: - 'app/models/task.rb' - 'app/models/unit.rb' -# Offense count: 36 -# Configuration parameters: NamePrefix, ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros. -# NamePrefix: is_, has_, have_ -# ForbiddenPrefixes: is_, has_, have_ +# Offense count: 39 +# Configuration parameters: NamePrefix, ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros, UseSorbetSigs. +# NamePrefix: is_, has_, have_, does_ +# ForbiddenPrefixes: is_, has_, have_, does_ # AllowedMethods: is_a? # MethodDefinitionMacros: define_method, define_singleton_method Naming/PredicateName: Exclude: - - 'spec/**/*' - 'app/api/entities/unit_entity.rb' - 'app/models/group.rb' - - 'app/models/overseer_assessment.rb' - 'app/models/project.rb' - 'app/models/task.rb' - 'app/models/task_definition.rb' @@ -913,37 +895,132 @@ Naming/PredicateName: - 'lib/tasks/checks.rake' - 'lib/tasks/generate_pdfs.rake' -# Offense count: 27 -# Configuration parameters: EnforcedStyle, AllowedIdentifiers, AllowedPatterns. +# Offense count: 46 +# Configuration parameters: EnforcedStyle, AllowedIdentifiers, AllowedPatterns, ForbiddenIdentifiers, ForbiddenPatterns. # SupportedStyles: snake_case, camelCase Naming/VariableName: Exclude: - 'app/api/task_comments_api.rb' - 'app/models/comments/task_comment.rb' - 'config/deakin.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/students_api_test.rb' + - 'test/models/tii_model_test.rb' -# Offense count: 2 +# Offense count: 26 # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. # SupportedStyles: snake_case, normalcase, non_integer -# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339 +# AllowedIdentifiers: TLS1_1, TLS1_2, capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 Naming/VariableNumber: Exclude: - - 'app/models/unit.rb' + - 'test/api/collection_pagination_test.rb' + - 'test/api/comments/comment_test.rb' + - 'test/api/d2l_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/models/task_definition_test.rb' -# Offense count: 3 +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Include. +# Include: **/test/**/* +Rails/AssertNot: + Exclude: + - 'test/api/units/task_definitions_api_test.rb' + +# Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). -Security/IoMethods: +# Configuration parameters: Whitelist, AllowedMethods, AllowedReceivers. +# Whitelist: find_by_sql, find_by_token_for +# AllowedMethods: find_by_sql, find_by_token_for +# AllowedReceivers: Gem::Specification, page +Rails/DynamicFindBy: Exclude: - - 'app/api/discussion_comment_api.rb' - - 'app/api/task_comments_api.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 13 +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: slashes, arguments +Rails/FilePath: + Exclude: + - 'test/api/api_root_test.rb' + - 'test/config/database_yml_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/config/student_import_weeks_before_test.rb' + - 'test/dx_a02_test.rb' + - 'test/models/file_helper_test.rb' + - 'test/models/task_definition_test.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowedMethods, AllowedPatterns. +# AllowedMethods: order, limit, select, lock +Rails/FindEach: + Exclude: + - 'test/models/task_status_test.rb' + - 'test/models/teaching_period_test.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Include. +# Include: spec/**/*, test/**/* +Rails/HttpPositionalArguments: + Exclude: + - 'test/helpers/json_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Rails/IndexWith: + Exclude: + - 'test/services/teams_announcement_sync_test.rb' + +# Offense count: 10 +# This cop supports safe autocorrection (--autocorrect). +Rails/PluralizationGrammar: + Exclude: + - 'test/api/breaks_api_test.rb' + - 'test/api/comments/extension_test.rb' + - 'test/models/task_test.rb' + - 'test/models/teaching_period_test.rb' + +# Offense count: 141 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, Include. +# SupportedStyles: assert_not, refute +# Include: **/test/**/* +Rails/RefuteMethods: + Enabled: false + +# Offense count: 19 +# Configuration parameters: ForbiddenMethods, AllowedMethods. +# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all +Rails/SkipsModelValidations: + Exclude: + - 'test/api/authentication_api_test.rb' + - 'test/api/engagements_api_test.rb' + - 'test/api/submission_history_access_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/lib/demo_data/all_features_scenario_test.rb' + - 'test/models/overseer_assessment_test.rb' + - 'test/models/submission_lifecycle_test.rb' + - 'test/sidekiq/communication_set_schedule_jobs_test.rb' + - 'test/sidekiq/send_due_soon_reminders_job_test.rb' + - 'test/sidekiq/send_new_task_available_notifications_job_test.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: strict, flexible +Rails/TimeZone: + Exclude: + - 'test/api/submission_history_access_test.rb' + +# Offense count: 6 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: separated, grouped Style/AccessorGrouping: Exclude: - - 'app/models/project.rb' - 'app/models/task.rb' # Offense count: 2 @@ -970,9 +1047,9 @@ Style/AndOr: - 'app/models/tutorial_enrolment.rb' - 'app/models/tutorial_stream.rb' -# Offense count: 7 +# Offense count: 8 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, IgnoredMethods, AllowBracesOnProceduralOneLiners, BracesRequiredMethods. +# Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods. # SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces # ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object # FunctionalMethods: let, let!, subject, watch @@ -985,29 +1062,30 @@ Style/BlockDelimiters: - 'app/models/project.rb' - 'config/deakin.rb' - 'lib/helpers/database_populator.rb' + - 'test/api/users_test.rb' + - 'test/api/webcal_api_test.rb' -# Offense count: 6 +# Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: MinBranchesCount. Style/CaseLikeIf: Exclude: - 'app/helpers/csv_helper.rb' - - 'app/helpers/file_helper.rb' - - 'app/models/comments/task_comment.rb' - - 'app/models/user.rb' -# Offense count: 3 +# Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. +# Configuration parameters: EnforcedStyle, EnforcedStyleForClasses, EnforcedStyleForModules. # SupportedStyles: nested, compact +# SupportedStylesForClasses: , nested, compact +# SupportedStylesForModules: , nested, compact Style/ClassAndModuleChildren: Exclude: - - 'app/api/entities/minimal/minimal_unit_entity.rb' - - 'app/api/entities/minimal/minimal_user_entity.rb' - 'app/api/submission/generate_helpers.rb' + - 'test/test_helper.rb' -# Offense count: 12 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods. +# Offense count: 4 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowedMethods, AllowedPatterns. # AllowedMethods: ==, equal?, eql? Style/ClassEqualityComparison: Exclude: @@ -1015,6 +1093,11 @@ Style/ClassEqualityComparison: - 'app/helpers/db_helpers.rb' - 'app/models/task_definition.rb' +# Offense count: 1 +Style/ClassVars: + Exclude: + - 'test/config/deakin_config_test.rb' + # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). Style/ColonMethodCall: @@ -1022,6 +1105,7 @@ Style/ColonMethodCall: - 'app/helpers/timeout_helper.rb' # Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). Style/CombinableLoops: Exclude: - 'app/models/unit.rb' @@ -1034,49 +1118,46 @@ Style/CommentAnnotation: Exclude: - 'app/api/task_definitions_api.rb' -# Offense count: 24 +# Offense count: 20 # This cop supports unsafe autocorrection (--autocorrect-all). Style/CommentedKeyword: Exclude: - 'app/api/projects_api.rb' - - 'app/api/submission/batch_task_api.rb' - 'app/api/submission/portfolio_api.rb' - 'app/api/submission/portfolio_evidence_api.rb' - 'app/models/unit.rb' - 'config/deakin.rb' -# Offense count: 13 +# Offense count: 12 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions. # SupportedStyles: assign_to_condition, assign_inside_condition Style/ConditionalAssignment: Exclude: - - 'app/api/submission/portfolio_evidence_api.rb' - 'app/models/comments/extension_comment.rb' - - 'app/models/learning_outcome_task_link.rb' - 'app/models/task.rb' - 'app/models/unit.rb' - 'lib/helpers/database_populator.rb' - 'lib/tasks/maintenance.rake' -# Offense count: 11 +# Offense count: 10 # This cop supports safe autocorrection (--autocorrect). Style/DefWithParentheses: Exclude: - 'app/helpers/file_helper.rb' - - 'app/models/overseer_assessment.rb' - 'app/models/task_definition.rb' - 'app/models/user.rb' - 'config/deakin.rb' + - 'test/api/units_api_test.rb' -# Offense count: 120 +# Offense count: 291 # Configuration parameters: AllowedConstants. Style/Documentation: Enabled: false # Offense count: 3 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, AllowComments. +# Configuration parameters: AutoCorrect, EnforcedStyle, AllowComments. # SupportedStyles: empty, nil, both Style/EmptyElse: Exclude: @@ -1099,36 +1180,30 @@ Style/ExpandPathArguments: - 'config/application.rb' - 'script/rails' -# Offense count: 1 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). Style/ExplicitBlockArgument: Exclude: - 'app/helpers/timeout_helper.rb' + - 'test/models/file_helper_test.rb' + - 'test/models/task_similarity_test.rb' -# Offense count: 31 +# Offense count: 57 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - - 'config/application.rb' - - 'config/deakin.rb' - - 'config/environments/production.rb' - -# Offense count: 3 -# This cop supports safe autocorrection (--autocorrect). -Style/FileRead: - Exclude: - - 'app/helpers/file_helper.rb' - - 'app/models/unit.rb' - - 'lib/tasks/checks.rake' - -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Style/FileWrite: - Exclude: - - 'lib/tasks/generate_pdfs.rake' + - 'test/api/tii/tii_hook_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/models/task_similarity_test.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/tii_model_test.rb' + - 'test/models/tii_user_accept_eula_test.rb' + - 'test/services/teams_announcement_sync_test.rb' + - 'test/sidekiq/tii_check_progress_job_test.rb' + - 'test/sidekiq/tii_webhooks_job_test.rb' -# Offense count: 19 +# Offense count: 18 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: each, for @@ -1153,27 +1228,28 @@ Style/FormatString: Exclude: - 'app/models/project.rb' -# Offense count: 8 +# Offense count: 6 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns, IgnoredMethods. +# Configuration parameters: MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns. # SupportedStyles: annotated, template, unannotated +# AllowedMethods: redirect Style/FormatStringToken: EnforcedStyle: template -# Offense count: 152 +# Offense count: 493 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: always, always_true, never Style/FrozenStringLiteralComment: Enabled: false -# Offense count: 2 +# Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). Style/GlobalStdStream: Exclude: - 'lib/tasks/skip_prod.rake' -# Offense count: 46 +# Offense count: 75 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. Style/GuardClause: @@ -1183,7 +1259,7 @@ Style/GuardClause: # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. # SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys -# SupportedShorthandSyntax: always, never, either, consistent +# SupportedShorthandSyntax: always, never, either, consistent, either_consistent Style/HashSyntax: Exclude: - 'app/models/campus.rb' @@ -1191,67 +1267,59 @@ Style/HashSyntax: - 'app/models/unit.rb' - 'lib/helpers/database_populator.rb' -# Offense count: 4 +# Offense count: 5 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowIfModifier. Style/IfInsideElse: Exclude: - 'app/api/units_api.rb' - - 'app/models/learning_outcome_task_link.rb' - 'app/models/task.rb' + - 'test/models/unit_model_test.rb' -# Offense count: 316 +# Offense count: 682 # This cop supports safe autocorrection (--autocorrect). Style/IfUnlessModifier: Enabled: false -# Offense count: 2 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: AllowedMethods. -# AllowedMethods: nonzero? -Style/IfWithBooleanLiteralBranches: - Exclude: - - 'config/application.rb' - -# Offense count: 5 +# Offense count: 3 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: InverseMethods, InverseBlocks. Style/InverseMethods: Exclude: - 'app/api/entities/task_entity.rb' - 'app/api/submission/generate_helpers.rb' - - 'app/helpers/file_helper.rb' - 'app/models/unit.rb' -# Offense count: 15 +# Offense count: 19 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: line_count_dependent, lambda, literal Style/Lambda: Exclude: - - 'app/api/entities/project_entity.rb' - 'app/api/entities/unit_entity.rb' - 'app/models/project.rb' - 'app/models/unit.rb' - 'config/deakin.rb' + - 'test/models/webcal_test.rb' -# Offense count: 25 +# Offense count: 35 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredMethods. +# Configuration parameters: AllowedMethods, AllowedPatterns. Style/MethodCallWithoutArgsParentheses: Exclude: - 'app/api/task_definitions_api.rb' - 'app/models/comments/discussion_comment.rb' - - 'app/models/overseer_assessment.rb' - 'app/models/project.rb' - - 'app/models/task.rb' - 'app/models/task_definition.rb' - 'app/models/unit.rb' - 'config/deakin.rb' - 'lib/helpers/database_populator.rb' - 'lib/tasks/checks.rake' + - 'test/api/units/task_definitions_api_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/models/tii_model_test.rb' -# Offense count: 12 +# Offense count: 11 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: require_parentheses, require_no_parentheses, require_no_parentheses_except_multiline @@ -1262,22 +1330,22 @@ Style/MethodDefParentheses: - 'app/models/group_submission.rb' - 'app/models/task_definition.rb' - 'config/deakin.rb' - - 'lib/helpers/database_populator.rb' + - 'test/models/unit_model_test.rb' # Offense count: 1 Style/MultilineBlockChain: Exclude: - 'app/models/project.rb' -# Offense count: 1 +# Offense count: 3 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: literals, strict Style/MutableConstant: Exclude: - - 'app/helpers/grade_helper.rb' + - 'test/services/teams_graph_client_test.rb' -# Offense count: 6 +# Offense count: 4 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: both, prefix, postfix @@ -1285,16 +1353,7 @@ Style/NegatedIf: Exclude: - 'app/api/task_definitions_api.rb' - 'app/api/units_api.rb' - - 'app/helpers/file_helper.rb' - 'app/models/task.rb' - - 'app/models/task_definition.rb' - -# Offense count: 3 -# This cop supports safe autocorrection (--autocorrect). -Style/NegatedIfElseCondition: - Exclude: - - 'app/models/project.rb' - - 'app/models/unit.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). @@ -1302,14 +1361,13 @@ Style/NestedTernaryOperator: Exclude: - 'app/models/project.rb' -# Offense count: 7 +# Offense count: 6 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MinBodyLength. +# Configuration parameters: EnforcedStyle, MinBodyLength, AllowConsecutiveConditionals. # SupportedStyles: skip_modifier_ifs, always Style/Next: Exclude: - 'app/models/teaching_period.rb' - - 'app/models/unit.rb' - 'config/deakin.rb' # Offense count: 2 @@ -1329,20 +1387,20 @@ Style/Not: - 'app/models/task_definition.rb' - 'app/models/tutorial_enrolment.rb' -# Offense count: 1 +# Offense count: 5 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: Strict, AllowedNumbers, AllowedPatterns. Style/NumericLiterals: MinDigits: 6 -# Offense count: 90 +# Offense count: 104 # This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns, IgnoredMethods. +# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. # SupportedStyles: predicate, comparison Style/NumericPredicate: Enabled: false -# Offense count: 23 +# Offense count: 26 # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: @@ -1351,12 +1409,9 @@ Style/OptionalBooleanParameter: - 'app/models/auth_token.rb' - 'app/models/comments/extension_comment.rb' - 'app/models/portfolio_evidence.rb' - - 'app/models/project.rb' - 'app/models/task.rb' - 'app/models/task_definition.rb' - - 'app/models/teaching_period.rb' - 'app/models/unit.rb' - - 'app/models/user.rb' - 'lib/helpers/faker_randomiser.rb' # Offense count: 2 @@ -1365,60 +1420,62 @@ Style/OrAssignment: Exclude: - 'app/models/unit.rb' -# Offense count: 4 +# Offense count: 1 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowSafeAssignment, AllowInMultilineConditions. Style/ParenthesesAroundCondition: Exclude: - - 'app/models/group.rb' - - 'app/models/task_definition.rb' - 'config/application.rb' -# Offense count: 29 +# Offense count: 79 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: PreferredDelimiters. Style/PercentLiteralDelimiters: Exclude: - - 'app/api/task_comments_api.rb' - 'app/helpers/file_helper.rb' - 'app/models/learning_outcome.rb' - - 'app/models/learning_outcome_task_link.rb' - 'app/models/task.rb' - 'app/models/task_definition.rb' - 'app/models/unit.rb' - 'app/models/user.rb' - 'app/models/webcal.rb' - 'config/application.rb' + - 'test/api/activity_types_api_test.rb' + - 'test/api/breaks_api_test.rb' + - 'test/api/campuses_test.rb' + - 'test/api/comments/comment_test.rb' + - 'test/api/feedback/learning_outcome_api_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/api/teaching_period_api_test.rb' + - 'test/api/tii/tii_group_attachment_api_test.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/api/units/task_definitions_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/api/users_test.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/tii_model_test.rb' -# Offense count: 12 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: . +# Configuration parameters: EnforcedStyle. # SupportedStyles: same_as_string_literals, single_quotes, double_quotes Style/QuotedSymbols: EnforcedStyle: double_quotes - -# Offense count: 5 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Methods. -Style/RedundantArgument: Exclude: - - 'app/helpers/csv_helper.rb' - - 'app/models/unit.rb' - - 'app/models/user.rb' + - 'test/api/tii/tii_action_api_test.rb' -# Offense count: 5 +# Offense count: 3 # This cop supports safe autocorrection (--autocorrect). Style/RedundantBegin: Exclude: - 'app/helpers/timeout_helper.rb' - - 'app/models/task_definition.rb' - 'app/models/unit.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). -Style/RedundantConstantBase: +Style/RedundantDoubleSplatHashBraces: Exclude: - - 'config.ru' + - 'test/api/collection_pagination_test.rb' # Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). @@ -1427,40 +1484,29 @@ Style/RedundantFetchBlock: Exclude: - 'config/puma.rb' -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -Style/RedundantFileExtensionInRequire: - Exclude: - - 'lib/tasks/register_q_assessment_results_subscriber.rake' - -# Offense count: 11 +# Offense count: 17 # This cop supports unsafe autocorrection (--autocorrect-all). Style/RedundantInterpolation: Exclude: - 'app/helpers/file_helper.rb' - 'app/models/task_definition.rb' - 'lib/helpers/database_populator.rb' + - 'test/api/units_api_test.rb' -# Offense count: 6 +# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). Style/RedundantParentheses: Exclude: - - 'app/models/project.rb' - 'app/models/task.rb' - - 'app/models/task_definition.rb' - 'config/application.rb' -# Offense count: 17 +# Offense count: 8 # This cop supports safe autocorrection (--autocorrect). Style/RedundantRegexpEscape: Exclude: - - 'app/api/discussion_comment_api.rb' - - 'app/api/task_comments_api.rb' - 'app/helpers/csv_helper.rb' - - 'app/helpers/file_helper.rb' - - 'app/models/project.rb' -# Offense count: 20 +# Offense count: 22 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowMultipleReturnValues. Style/RedundantReturn: @@ -1473,8 +1519,9 @@ Style/RedundantReturn: - 'app/models/task_status.rb' - 'app/models/user.rb' - 'app/models/webcal.rb' + - 'test/helpers/auth_helper.rb' -# Offense count: 88 +# Offense count: 174 # This cop supports safe autocorrection (--autocorrect). Style/RedundantSelf: Enabled: false @@ -1485,13 +1532,7 @@ Style/RedundantSort: Exclude: - 'app/models/project.rb' -# Offense count: 2 -# This cop supports safe autocorrection (--autocorrect). -Style/RedundantStringEscape: - Exclude: - - 'app/api/authentication_api.rb' - -# Offense count: 13 +# Offense count: 17 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, AllowInnerSlashes. # SupportedStyles: slashes, percent_r, mixed @@ -1503,10 +1544,11 @@ Style/RegexpLiteral: - 'app/controllers/task_submission_pdfs_controller.rb' - 'app/helpers/csv_helper.rb' - 'app/helpers/file_helper.rb' - - 'app/models/project.rb' - 'app/models/task.rb' - - 'app/models/task_definition.rb' - - 'app/models/unit.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/api/units/task_definitions_api_test.rb' + - 'test/models/tii_group_attachment_test.rb' + - 'test/models/tii_model_test.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). @@ -1514,7 +1556,7 @@ Style/RescueModifier: Exclude: - 'app/models/unit.rb' -# Offense count: 27 +# Offense count: 22 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: implicit, explicit @@ -1531,14 +1573,14 @@ Style/RescueStandardError: - 'app/models/user.rb' - 'lib/tasks/checks.rake' - 'lib/tasks/maintenance.rake' + - 'test/models/teaching_period_test.rb' -# Offense count: 29 +# Offense count: 41 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. # AllowedMethods: present?, blank?, presence, try, try! Style/SafeNavigation: Exclude: - - 'app/api/entities/minimal/minimal_unit_entity.rb' - 'app/api/entities/task_definition_entity.rb' - 'app/api/entities/tutorial_entity.rb' - 'app/api/entities/unit_entity.rb' @@ -1548,13 +1590,12 @@ Style/SafeNavigation: - 'app/models/tutorial.rb' - 'app/models/unit.rb' - 'app/models/user.rb' - - 'lib/assets/ontrack_receive_action.rb' -# Offense count: 1 +# Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). Style/SelectByRegexp: Exclude: - - 'app/helpers/file_helper.rb' + - 'test/api/api_root_test.rb' # Offense count: 3 # This cop supports safe autocorrection (--autocorrect). @@ -1562,33 +1603,31 @@ Style/SelfAssignment: Exclude: - 'app/models/unit.rb' -# Offense count: 4 +# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowAsExpressionSeparator. Style/Semicolon: Exclude: - - 'app/models/unit.rb' - 'lib/helpers/database_populator.rb' - 'lib/tasks/generate_pdfs.rake' -# Offense count: 2 +# Offense count: 3 # This cop supports unsafe autocorrection (--autocorrect-all). Style/SlicingWithRange: Exclude: - 'app/models/task.rb' -# Offense count: 8 +# Offense count: 7 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowModifier. Style/SoleNestedConditional: Exclude: - 'app/api/group_sets_api.rb' - - 'app/api/task_definitions_api.rb' - 'app/models/group.rb' - 'app/models/task.rb' - 'config/deakin.rb' -# Offense count: 10 +# Offense count: 14 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: Mode. Style/StringConcatenation: @@ -1596,15 +1635,18 @@ Style/StringConcatenation: - 'app/models/portfolio_evidence.rb' - 'app/models/task.rb' - 'app/models/unit.rb' + - 'test/models/push_subscription_test.rb' + - 'test/services/push_notification_service_test.rb' + - 'test/sidekiq/execute_communication_set_job_test.rb' -# Offense count: 349 +# Offense count: 1787 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. # SupportedStyles: single_quotes, double_quotes Style/StringLiterals: Enabled: false -# Offense count: 5 +# Offense count: 13 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: single_quotes, double_quotes @@ -1614,22 +1656,27 @@ Style/StringLiteralsInInterpolation: - 'app/models/task.rb' - 'config/deakin.rb' - 'lib/helpers/database_populator.rb' + - 'test/api/comments/extension_test.rb' + - 'test/config/deakin_config_test.rb' + - 'test/models/file_helper_test.rb' -# Offense count: 41 +# Offense count: 85 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, MinSize. # SupportedStyles: percent, brackets Style/SymbolArray: Enabled: false -# Offense count: 5 +# Offense count: 14 # This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, IgnoredMethods, AllowComments. -# AllowedMethods: define_method +# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, AllowComments. +# AllowedMethods: define_method, mail, respond_to Style/SymbolProc: Exclude: - - 'app/models/teaching_period.rb' - 'app/models/unit.rb' + - 'test/api/tutorials_test.rb' + - 'test/api/webcal_api_test.rb' + - 'test/models/group_test.rb' # Offense count: 2 # This cop supports safe autocorrection (--autocorrect). @@ -1640,7 +1687,7 @@ Style/TernaryParentheses: - 'app/models/project.rb' - 'app/models/tutorial_stream.rb' -# Offense count: 5 +# Offense count: 18 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyleForMultiline. # SupportedStylesForMultiline: comma, consistent_comma, no_comma @@ -1649,50 +1696,77 @@ Style/TrailingCommaInArguments: - 'app/api/group_sets_api.rb' - 'app/api/units_api.rb' - 'app/models/unit.rb' + - 'test/api/d2l_test.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/models/tii_model_test.rb' + - 'test/models/tii_user_accept_eula_test.rb' + - 'test/sidekiq/tii_webhooks_job_test.rb' -# Offense count: 6 +# Offense count: 23 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma +# SupportedStylesForMultiline: comma, consistent_comma, diff_comma, no_comma Style/TrailingCommaInArrayLiteral: Exclude: - 'app/models/task.rb' - 'app/models/unit.rb' - 'lib/helpers/database_populator.rb' + - 'test/api/csv_test.rb' + - 'test/api/units_api_test.rb' + - 'test/helpers/tii_test_helper.rb' + - 'test/models/tii_model_test.rb' + - 'test/models/webcal_test.rb' -# Offense count: 7 +# Offense count: 19 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma +# SupportedStylesForMultiline: comma, consistent_comma, diff_comma, no_comma Style/TrailingCommaInHashLiteral: Exclude: - 'app/models/comments/task_comment.rb' - 'app/models/unit.rb' - 'app/models/webcal.rb' - 'lib/helpers/database_populator.rb' + - 'test/api/comments/status_test.rb' + - 'test/api/csv_test.rb' + - 'test/api/feedback/feedback_chip_api_consolidated_test.rb' + - 'test/api/feedback/learning_outcome_api_test.rb' + - 'test/api/group_sets_api_test.rb' + - 'test/models/teaching_period_test.rb' -# Offense count: 13 +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/UnlessElse: + Exclude: + - 'test/factories/units_factory.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/WhileUntilModifier: + Exclude: + - 'test/factories/users_factory.rb' + +# Offense count: 21 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, MinSize, WordRegex. # SupportedStyles: percent, brackets Style/WordArray: Exclude: - 'app/api/campuses_authenticated_api.rb' - - 'app/api/entities/comment_entity.rb' - - 'app/models/comments/task_comment.rb' - 'app/models/task.rb' - 'config/deakin.rb' - 'lib/helpers/database_populator.rb' + - 'test/api/tii/tii_hook_test.rb' + - 'test/api/units/task_definitions_api_test.rb' + - 'test/api/units_api_test.rb' + - 'test/factories/campuses_factory.rb' + - 'test/helpers/tii_test_helper.rb' + - 'test/models/tii_model_test.rb' + - 'test/sidekiq/tii_webhooks_job_test.rb' -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/ZeroLengthPredicate: - Exclude: - - 'app/models/unit.rb' - -# Offense count: 583 +# Offense count: 1208 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns. +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. # URISchemes: http, https Layout/LineLength: Max: 369 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000000..84d6c67654 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.10 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..b8a27c4fe4 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.4.10 diff --git a/Dockerfile b/Dockerfile index cb98ae2be8..9604964772 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.4-bookworm +FROM ruby:3.4-bookworm AS dependencies # DEBIAN_FRONTEND=noninteractive is required to install tzdata in non interactive way ENV DEBIAN_FRONTEND=noninteractive @@ -49,7 +49,17 @@ COPY docker-entrypoint.sh /usr/bin/ RUN chmod +x /usr/bin/docker-entrypoint.sh ENTRYPOINT ["docker-entrypoint.sh"] +# CI always bind-mounts the checked-out source over /doubtfire. Stop this stage +# before the application copy so source-only changes do not invalidate or load +# a layer that the test container immediately hides. +FROM dependencies AS ci + +ENV RAILS_ENV=test +CMD ["bash"] + # Copy code locally to allow container to be used without the code volume +FROM dependencies AS development + COPY . . EXPOSE 3000 diff --git a/FETCH_HEAD b/FETCH_HEAD deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/Gemfile b/Gemfile index b367b82225..11e0e2fb03 100644 --- a/Gemfile +++ b/Gemfile @@ -13,12 +13,11 @@ ruby_versions = { ruby ruby_versions[(ENV['RAILS_ENV'] || 'development').to_sym] # The venerable, almighty Rails -gem 'rails', '~>8.0' +gem 'rails', '~> 8.0.0', '>= 8.0.5.1' group :development, :test do gem 'better_errors' gem 'byebug' - gem 'database_cleaner-active_record' gem 'listen' gem 'rails_best_practices' gem 'rubocop' @@ -48,7 +47,7 @@ end gem 'mysql2' # Webserver - included in development and test and optionally in production -gem 'puma' +gem 'puma', '~> 7.2', '>= 7.2.1' gem 'bootsnap', require: false gem 'csv' @@ -60,6 +59,7 @@ gem 'hirb' gem 'devise' gem 'devise_ldap_authenticatable' gem 'json-jwt' +gem 'rack-attack', '~> 6.8' gem 'ruby-saml' # Student submission @@ -124,3 +124,11 @@ gem "sys-filesystem" gem "sentry-rails" gem "sentry-ruby" + +# Web push notifications. Signs and encrypts payloads for the browser push +# services (VAPID). See docs/notifications/push-setup.md. +# +# Pinned exactly so a future dependency update cannot unexpectedly move JWT to +# a new major version. web-push 3.0.1 still supports jwt ~> 2.0 and replaces the +# retired hkdf dependency with OpenSSL::KDF; JWT 3 is introduced by 3.0.2. +gem 'web-push', '3.0.1' diff --git a/Gemfile.lock b/Gemfile.lock index 9df7ab4c0a..c325baf15d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,29 +2,29 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (2.0.1) - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.5.1) + actionpack (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activesupport (= 8.0.5.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.5.1) + actionview (= 8.0.5.1) + activesupport (= 8.0.5.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -32,35 +32,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.5.1) + actionpack (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.5.1) + activesupport (= 8.0.5.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.5.1) + activesupport (= 8.0.5.1) + activerecord (8.0.5.1) + activemodel (= 8.0.5.1) + activesupport (= 8.0.5.1) timeout (>= 0.4.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activesupport (= 8.0.5.1) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.5.1) base64 benchmark (>= 0.3) bigdecimal @@ -73,14 +73,18 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) afm (0.2.2) amq-protocol (2.3.3) + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) ast (2.4.3) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) backport (1.2.0) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) benchmark (0.4.0) better_errors (2.10.1) @@ -106,21 +110,17 @@ GEM code_analyzer (0.5.5) sexp_processor coderay (1.1.3) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.8) connection_pool (2.5.0) crack (1.0.0) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) cronex (0.15.0) tzinfo unicode (>= 0.4.4.5) csv (3.3.3) - database_cleaner-active_record (2.2.0) - activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) - database_cleaner-core (2.0.1) - date (3.4.1) + date (3.5.1) devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -165,7 +165,7 @@ GEM railties (>= 5.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.2) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger @@ -222,13 +222,13 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects - jwt (2.10.1) + jwt (2.10.3) base64 kramdown (2.5.1) rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - language_server-protocol (3.17.0.4) + language_server-protocol (3.17.0.6) lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -267,7 +267,7 @@ GEM mysql2 (0.5.6) net-http (0.6.0) uri - net-imap (0.5.6) + net-imap (0.6.6) date net-protocol net-ldap (0.19.0) @@ -279,19 +279,23 @@ GEM net-protocol netrc (0.11.0) nio4r (2.7.4) - nokogiri (1.18.7-aarch64-linux-gnu) + nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.7-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) numerizer (0.1.1) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) observer (0.1.2) + openssl (3.3.3) orm_adapter (0.5.0) ostruct (0.6.1) parallel (1.26.3) @@ -308,39 +312,41 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) - prism (1.4.0) + prism (1.9.0) psych (5.2.3) date stringio public_suffix (6.0.1) - puma (6.6.0) + puma (7.2.1) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.12) + rack (3.1.22) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-cors (2.0.2) rack (>= 2.0.0) - rack-session (2.1.0) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + rails (8.0.5.1) + actioncable (= 8.0.5.1) + actionmailbox (= 8.0.5.1) + actionmailer (= 8.0.5.1) + actionpack (= 8.0.5.1) + actiontext (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activemodel (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) bundler (>= 1.15.0) - railties (= 8.0.2) + railties (= 8.0.5.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -358,21 +364,23 @@ GEM json require_all (~> 3.0) ruby-progressbar - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.9.2) + rbs (3.10.4) logger + tsort rbtree (0.4.6) rdoc (6.13.1) psych (>= 4.0.0) @@ -442,15 +450,14 @@ GEM rubocop (>= 1.72.1, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) ruby-filemagic (0.7.3) - ruby-lsp (0.23.13) + ruby-lsp (0.26.9) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) - rbs (>= 3, < 4) - sorbet-runtime (>= 0.5.10782) + rbs (>= 3, < 5) ruby-ole (1.2.13.1) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) - ruby-saml (1.18.0) + ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) @@ -490,9 +497,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) + snaky_hash (2.0.7) + hashie (>= 0.1.0, < 6) + version_gem (~> 1.1, >= 1.1.14) solargraph (0.53.4) backport (~> 1.2) benchmark @@ -512,7 +519,6 @@ GEM tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) yard-solargraph (~> 0.1) - sorbet-runtime (0.5.11966) sorted_set (1.0.3) rbtree set (~> 1.0) @@ -535,7 +541,8 @@ GEM tcp_timeout (0.1.1) thor (1.3.2) tilt (2.6.0) - timeout (0.4.3) + timeout (0.6.1) + tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) typhoeus (1.4.1) @@ -546,20 +553,23 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) - uri (1.0.3) + uri (1.0.4) useragent (0.16.11) - version_gem (1.1.6) + version_gem (1.1.15) warden (1.2.9) rack (>= 2.0.9) + web-push (3.0.1) + jwt (~> 2.0) + openssl (~> 3.0) webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.7) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - yard (0.9.37) + yard (0.9.45) yard-solargraph (0.1.0) yard (~> 0.9) zeitwerk (2.7.2) @@ -576,7 +586,6 @@ DEPENDENCIES ci_reporter coderay csv - database_cleaner-active_record devise devise_ldap_authenticatable dotenv @@ -600,9 +609,10 @@ DEPENDENCIES net-smtp oauth2 pdf-reader - puma + puma (~> 7.2, >= 7.2.1) + rack-attack (~> 6.8) rack-cors - rails (~> 8.0) + rails (~> 8.0.0, >= 8.0.5.1) rails-latex rails_best_practices redis @@ -633,10 +643,240 @@ DEPENDENCIES sprockets-rails sys-filesystem tca_client + web-push (= 3.0.1) webmock +CHECKSUMS + Ascii85 (2.0.1) sha256=15cb5d941808543cbb9e7e6aea3c8ec3877f154c3461e8b3673e97f7ecedbe5a + actioncable (8.0.5.1) sha256=5adb700c605a7ef7628f87dc7a6da20cd5f0ceac782a59055c864ea51a77d7c7 + actionmailbox (8.0.5.1) sha256=f8b72eadf53b3e285df8f2d1f6533012abf5a0a001180abe436aea3139eeaed6 + actionmailer (8.0.5.1) sha256=c3d2b3f96e1989ea25f51699786a97fcb2536eb7abfc2a667cb8f2376ec08403 + actionpack (8.0.5.1) sha256=a5595c9d824d68884ddc4d3965ab78c897760d3752e190df7efe897371caa1eb + actiontext (8.0.5.1) sha256=370e90d35feb4313fc18ccef658776427d5bdd13126f266933b828a77e2125b2 + actionview (8.0.5.1) sha256=472a108b9cc2295c4ac3ff09b028045e619875801f48c556f0085210b9cb1440 + activejob (8.0.5.1) sha256=142407a21b6c3cbc6ddd92ca111ac18ea5c40298eb94d81845cd897a072a6880 + activemodel (8.0.5.1) sha256=559be32aa9c40db7a3ee0aef926d4508a9ebd22f96f7276c11326d21a7dff4a4 + activerecord (8.0.5.1) sha256=9252968fce404d75eb17092498a440d472167f2f8deee32b4658d6552b1eeea7 + activestorage (8.0.5.1) sha256=239742932b2fdcf0ead175e0889dbd385a36da2168fd7bde023aaad88ef745f2 + activesupport (8.0.5.1) sha256=329a4280c4fbcfcf338ae2cb9df28b0b14527929dba105e10b3604516d998710 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + aes_key_wrap (1.1.0) sha256=b935f4756b37375895db45669e79dfcdc0f7901e12d4e08974d5540c8e0776a5 + afm (0.2.2) sha256=c83e698e759ab0063331ff84ca39c4673b03318f4ddcbe8e90177dd01e4c721a + amq-protocol (2.3.3) sha256=85b42738290913a35dcc487a2ca0dd260a4150b40ed1954c9c1932df466abc1f + anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + auth-sanitizer (0.2.3) sha256=db10aac92cfbe4c64ab637eebcbe1d67395d1694798041362173370f59933e3c + backport (1.2.0) sha256=912c7dfdd9ee4625d013ddfccb6205c3f92da69a8990f65c440e40f5b2fc7f75 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099 + benchmark (0.4.0) sha256=0f12f8c495545e3710c3e4f0480f63f06b4c842cc94cec7f33a956f5180e874a + better_errors (2.10.1) sha256=f798f1bac93f3e775925b7fcb24cffbcf0bb62ee2210f5350f161a6b75fc0a73 + bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc + bindata (2.5.0) sha256=29dccb8ba1cc9de148f24bb88930840c62db56715f0f80eccadd624d9f3d2623 + bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bunny (2.24.0) sha256=072fe4ae98eaa9c95a17e4d166204f710bba8a9a7070b73a8c3b023f439d1682 + bunny-pub-sub (0.5.2) sha256=cc8bef8007915a4b35f750955a13df128ce5332162f9755910172479edad01f0 + byebug (12.0.0) sha256=d4a150d291cca40b66ec9ca31f754e93fed8aa266a17335f71bb0afa7fca1a1e + chronic_duration (0.10.6) sha256=fac58d4147d3183a40811400380cafcef049f2bb02421d2fd1c6e685fbe8facc + ci_reporter (2.1.0) sha256=8ab6c378e3ea6af4f99790523ef52049405399156992fc5f51284b59b5728a61 + code_analyzer (0.5.5) sha256=c81533e9986259657acb9b3321d831efb1720ef59eed37e7e5dec56ac368e03e + coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (2.5.0) sha256=233b92f8d38e038c1349ccea65dd3772727d669d6d2e71f9897c8bf5cd53ebfc + crack (1.0.0) sha256=c83aefdb428cdc7b66c7f287e488c796f055c0839e6e545fec2c7047743c4a49 + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + cronex (0.15.0) sha256=21c794e085fad2951c4f2e279f440340a35ba2297e0b738f22f263f69fbe2186 + csv (3.3.3) sha256=7e2966befb7bdaf7d5e9b36e1de73e6a5e7a72f584f180a1726aec88a1b0a900 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 + devise_ldap_authenticatable (0.8.7) sha256=8af6f839661e24ca9afc5a1508a7ec7e1327e93af4516f2baabacdf511ee5a2e + diff-lcs (1.6.1) sha256=12a5a83f3e37a8e2f4427268e305914d5f1879f22b4e73bb1a09f76a3dd86cd4 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (3.1.7) sha256=c670df478675d23889e657beaca6fb423228f75ce9f052a0690c0d0daa333cf3 + drb (2.2.1) sha256=e9d472bf785f558b96b25358bae115646da0dbfd45107ad858b0bc0d935cb340 + dry-core (1.1.0) sha256=0903821a9707649a7da545a2cd88e20f3a663ab1c5288abd7f914fa7751ab195 + dry-inflector (1.2.0) sha256=22f5d0b50fd57074ae57e2ca17e3b300e57564c218269dcf82ff3e42d3f38f2e + dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 + dry-types (1.8.2) sha256=c84e9ada69419c727c3b12e191e0ed7d2c6d58d040d55e79ea16e0ebf8b3ec0f + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + erubis (2.7.0) sha256=63653f5174a7997f6f1d6f465fbe1494dcc4bdab1fb8e635f6216989fb1148ba + et-orbi (1.2.11) sha256=d26e868cc21db88280a9ec1a50aa3da5d267eb9b2037ba7b831d6c2731f5df64 + ethon (0.16.0) sha256=bba0da1cea8ac3e1f5cdd7cb1cb5fc78d7ac562c33736f18f0c3eb2b63053d9e + factory_bot (6.5.1) sha256=40581ea7bec0aee05514b8f4f99ed477274bdf1884c1372de5209e60322d6ca9 + factory_bot_rails (6.4.4) sha256=139e17caa2c50f098fddf5e5e1f29e8067352024e91ca1186d018b36589e5c88 + faker (3.5.1) sha256=1ad1fbea279d882f486059c23fe3ddb816ccd1d7052c05a45014b4450d859bfc + faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 + faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 + faraday-net_http (3.4.0) sha256=a1f1e4cd6a2cf21599c8221595e27582d9936819977bbd4089a601f24c64e54a + ffi (1.17.1-aarch64-linux-gnu) sha256=c5d22cb545a3a691d46060f1343c461d1a8d38c3fd71b96b4cbbe6906bf1fd38 + ffi (1.17.1-x86_64-linux-gnu) sha256=8c0ade2a5d19f3672bccfe3b58e016ae5f159e3e2e741c856db87fcf07c903d0 + fugit (1.11.1) sha256=e89485e7be22226d8e9c6da411664d0660284b4b1c08cacb540f505907869868 + globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 + grape (2.3.0) sha256=99484ae2907b06a9e109edf2911c383809bf7f7c00d65554e4d01f0388728bda + grape-entity (1.0.1) sha256=e00f9e94e407aff77aa2945d741f544d07e48501927942988799913151d02634 + grape-swagger (2.1.2) sha256=8ad7bd53c8baee704575808875dba8c08d269c457db3cf8f1b8a2a1dbf827294 + grape-swagger-rails (0.6.0) sha256=4e518cf0dd2d5b2d0345fc615067c56ea9331e23d932d08d6ebec051de11ff06 + hashdiff (1.1.2) sha256=2c30eeded6ed3dce8401d2b5b99e6963fe5f14ed85e60dd9e33c545a44b71a77 + hashery (2.1.2) sha256=d239cc2310401903f6b79d458c2bbef5bf74c46f3f974ae9c1061fb74a404862 + hashie (5.0.0) sha256=9d6c4e51f2a36d4616cbc8a322d619a162d8f42815a792596039fc95595603da + hirb (0.7.3) sha256=5132733ca44b1f41f36c624693a3201284368a349dfe37f543ae6e2ad880ec57 + http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f + icalendar (2.10.3) sha256=0ebfc2672f9fa77b86b4d8c0e25e9b2319aad45a33319fed06d0be8ddd0cd485 + ice_cube (0.17.0) sha256=32deb45dda4b4acc53505c2f581f6d32b5afc04d29b9004769944a0df5a5fcbe + io-console (0.8.0) sha256=cd6a9facbc69871d69b2cb8b926fc6ea7ef06f06e505e81a64f14a470fddefa2 + irb (1.15.1) sha256=d9bca745ac4207a8b728a52b98b766ca909b86ff1a504bcde3d6f8c84faae890 + jaro_winkler (1.6.0) sha256=8b081ab4ba7da5d16b438e62c4be58b87724bfeeb1527e62603f05ab0a2cc424 + json (2.10.2) sha256=34e0eada93022b2a0a3345bb0b5efddb6e9ff5be7c48e409cfb54ff8a36a8b06 + json-jwt (1.16.7) sha256=ccabff4c6d1a14276b23178e8bebe513ef236399b72a0b886d7ed94800d172a5 + jwt (2.10.3) sha256=e4d9352fbc7309b1a7448c7dd713dfe4d8c47077af80759cdbed8f878ea0b484 + kramdown (2.5.1) sha256=87bbb6abd9d3cebe4fc1f33e367c392b4500e6f8fa19dd61c0972cf4afe7368c + kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729 + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + listen (3.9.0) sha256=db9e4424e0e5834480385197c139cb6b0ae0ef28cc13310cfd1ca78377d59c67 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.24.0) sha256=61e6a710883abb8210887f3dc868cf3ed66594c509d9ff6987621efa6651ee1e + mail (2.8.1) sha256=ec3b9fadcf2b3755c78785cb17bc9a0ca9ee9857108a64b6f5cfc9c0b5bfc9ad + marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 + mime-types (3.6.2) sha256=6109148e6a6e656607510b74571deff8ecd9a97ab0dcec9b7431bdd0b74460af + mime-types-data (3.2025.0325) sha256=8557e0e43b0b3216c2a518290039c1b65ffdbd6639db241142f7459eeba3c668 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (5.25.5) sha256=391b6c6cb43a4802bfb7c93af1ebe2ac66a210293f4a3fb7db36f2fc7dc2c756 + minitest-around (0.5.0) sha256=b959cea84f5eedb493ca2143e24a3c2547c62bd40efb2258a23285033ab6dc97 + minitest-rails (8.0.0) sha256=7788731b9793ef302721f925bf4349e0b943093e6f6b3d68cf8ac9134cd954bc + moss_ruby (1.1.4) sha256=3a0ea108a189647feba1c5ef34c12eb3f89be5ea1ded7e5d75a9806cf6ff0031 + msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + multi_json (1.15.0) sha256=1fd04138b6e4a90017e8d1b804c039031399866ff3fbabb7822aea367c78615d + multi_xml (0.7.1) sha256=4fce100c68af588ff91b8ba90a0bb3f0466f06c909f21a32f4962059140ba61b + mustermann (3.0.3) sha256=d1f8e9ba2ddaed47150ddf81f6a7ea046826b64c672fbc92d83bce6b70657e88 + mustermann-grape (1.1.0) sha256=8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5 + mysql2 (0.5.6) sha256=70f447d45d6b3cc16b00f7dd30366f708a81b4093a35d026ff7135d778d8da33 + net-http (0.6.0) sha256=9621b20c137898af9d890556848c93603716cab516dc2c89b01a38b894e259fb + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-ldap (0.19.0) sha256=be2a379ccbd28fc75fb70a94af74e3a9a6866b84574247fc243e0abdd2f82f3d + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f + nio4r (2.7.4) sha256=d95dee68e0bb251b8ff90ac3423a511e3b784124e5db7ff5f4813a220ae73ca9 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + numerizer (0.1.1) sha256=10ec9efec62472b69a3a0e275a18a44baa595ce6e2e4cfc1678d5cb2974c336f + oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac + observer (0.1.2) sha256=d8a3107131ba661138d748e7be3dbafc0d82e732fffba9fccb3d7829880950ac + openssl (3.3.3) sha256=d46902138f2987c13122fab826030a11c2bb9b8a16394215cbfc5062c5e2d335 + orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + ostruct (0.6.1) sha256=09a3fb7ecc1fa4039f25418cc05ae9c82bd520472c5c6a6f515f03e4988cb817 + parallel (1.26.3) sha256=d86babb7a2b814be9f4b81587bf0b6ce2da7d45969fab24d8ae4bf2bb4d4c7ef + parser (3.3.7.4) sha256=2b26282274280e13f891080dc4ef3f65ce658d62e13255b246b28ec6754e98ab + pdf-reader (2.14.1) sha256=b45a4521c249a394ad7ad9e691bfd46d4d00998cfc4f019e4525afb4963b411b + pkg-config (1.6.0) sha256=d6548afbcc6a63a1493cfdd743693415948c597cc85d7b2537bd3d1a3eb1b660 + pp (0.6.2) sha256=947ec3120c6f92195f8ee8aa25a7b2c5297bb106d83b41baa02983686577b6ff + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + psych (5.2.3) sha256=84a54bb952d14604fea22d99938348814678782f58b12648fcdfa4d2fce859ee + public_suffix (6.0.1) sha256=61d44e1cab5cbbbe5b31068481cf16976dd0dc1b6b07bd95617ef8c5e3e00c6f + puma (7.2.1) sha256=d7bf0e9cabd532e0d401e142cd94e3ac531e993610e2d80e6fbf9c26961414b0 + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.1.22) sha256=db116c1462fd32dec8b942a808ebedd4e1dbf1fcd0b24c481ae32ee99ca1ebe0 + rack-attack (6.8.0) sha256=f2499fdebf85bcc05573a22dff57d24305ac14ec2e4156cd3c28d47cafeeecf2 + rack-cors (2.0.2) sha256=415d4e1599891760c5dc9ef0349c7fecdf94f7c6a03e75b2e7c2b54b82adda1b + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.2.1) sha256=f737191fd5c5b348b7f0a4412a3b86383f88c43e13b8217b63d4c8d90b9e798d + rails (8.0.5.1) sha256=c91cefbf38881876ddbe67b5e0246eb985d27d60c6bb1cd7034b4261de0ba495 + rails-dom-testing (2.2.0) sha256=e515712e48df1f687a1d7c380fd7b07b8558faa26464474da64183a7426fa93b + rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + rails-latex (2.3.5) sha256=8829129f833a8410666fa1f7b8c39ad2e90a1e5dbdece940d87d04b30ad0ab9f + rails_best_practices (1.23.2) sha256=b3f2e63766e99d087fa832a373b27f2a38e4a8aa2e406b166fa5d237ce3592ac + railties (8.0.5.1) sha256=da1958e1d9dab04691a2f8721b3ff7fab323715d37f103c19972dedfd644d5c7 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.2.1) sha256=46cb38dae65d7d74b6020a4ac9d48afed8eb8149c040eccf0523bec91907059d + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e + rbs (3.10.4) sha256=b17d7c4be4bb31a11a3b529830f0aa206a807ca42f2e7921a3027dfc6b7e5ce8 + rbtree (0.4.6) sha256=14eea4469b24fd2472542e5f3eb105d6344c8ccf36f0b56d55fdcfeb4e0f10fc + rdoc (6.13.1) sha256=62a0dac99493c94e8eb7a3fb44e55aefcb4cecb119f7991f25bddc5ed8d472f7 + redis (5.4.0) sha256=798900d869418a9fc3977f916578375b45c38247a556b61d58cba6bb02f7d06b + redis-client (0.24.0) sha256=ee65ee39cb2c38608b734566167fd912384f3c1241f59075e22858f23a085dbb + regexp_parser (2.10.0) sha256=cb6f0ddde88772cd64bff1dbbf68df66d376043fe2e66a9ef77fcb1b0c548c61 + reline (0.6.0) sha256=57620375dcbe56ec09bac7192bfb7460c716bbf0054dc94345ecaa5438e539d2 + require_all (3.0.0) sha256=937853faa2833388eab551107bf7bf87c6bba6b4800bac5ce469eda7b6a9fed0 + responders (3.1.1) sha256=92f2a87e09028347368639cfb468f5fefa745cb0dc2377ef060db1cdd79a341a + rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3 + reverse_markdown (3.0.0) sha256=ab228386765a0259835873cd07054b62939c40f620c77c247eafaaa3b23faca4 + rexml (3.4.1) sha256=c74527a9a0a04b4ec31dbe0dc4ed6004b960af943d8db42e539edde3a871abca + rmagick (6.1.1) sha256=df0171c0641956a172ed0bbf6bdcf2ea68ad7fa3ec09364705f32c2cdd3b8726 + roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e + roo-xls (1.2.0) sha256=e340d7458d5f084e30f5eb4dc80925b047ecc7802a09115eaaba11bd4e8384cd + rouge (4.5.1) sha256=2ac81c6dee7019bbc6600d4c2d641d730d65c165941400ebd924259067e690dd + rubocop (1.75.1) sha256=c12900c55b0b52e6ed1384f7f7575beb92047019ce37ca14b9572d80239adc29 + rubocop-ast (1.43.0) sha256=92cd649e336ce10212cb2f2b29028f487777ecc477f108f437a1dce1ee3db79a + rubocop-factory_bot (2.27.1) sha256=9d744b5916778c1848e5fe6777cc69855bd96548853554ec239ba9961b8573fe + rubocop-faker (1.3.0) sha256=cb9ac132d44f9d2db6d5f9f8f5714700bf4d272cbaef5bce4052f4270fdc5c9b + rubocop-minitest (0.37.1) sha256=dcdcc2c835a859193e50bc67296daaf95ac99f6410838119374df31490460d36 + rubocop-performance (1.24.0) sha256=e5bd39ff3e368395b9af886927cc37f5892f43db4bd6c8526594352d5b4440b5 + rubocop-rails (2.30.3) sha256=fc5a6506daa916d15e282cc806943afa64a020bf592b93a94025d89a2a78a715 + ruby-filemagic (0.7.3) sha256=9dedfac69c737be29efb4542a280e345a70ba2b6ba905a518abd9998c8f3a7d9 + ruby-lsp (0.26.9) sha256=33a01c001c00a76b4e821efc04ed7572983430f31ca5d6f3e343d0b6ccab4129 + ruby-ole (1.2.13.1) sha256=578d10dd2a797a2b35a1286c6fb2c9525f67c24791346fc8015d39f0ffa3cb72 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-rc4 (0.1.5) sha256=00cc40a39d20b53f5459e7ea006a92cf584e9bc275e2a6f7aa1515510e896c03 + ruby-saml (1.18.1) sha256=1b0e7a44aef150b4197955f5e015d593672e242cfdc5d06aa7554ec2350b9107 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sentry-rails (6.5.0) sha256=ebf9d4d82c740c3e0e4a0840f11f7bbd0cf648a30afd9c67e5b50bb07018a0e4 + sentry-ruby (6.5.0) sha256=3c57ae0d6a017aafcd9ac37114e38149a58534679dec5d4e9e8fc010b85f3a6b + set (1.1.1) sha256=6c7ac6c06d5907216395a4d5dae3ffe52ca5ee8a372befe6d4dea794383f98f0 + sexp_processor (4.17.3) sha256=5ef0d952565eeedb416519f678b6b41c6ab6700abba828f46986f2d85d295dae + shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94 + sidekiq (7.3.9) sha256=1108712e1def89002b28e3545d5ae15d4a57ffd4d2c25d97bb1360988826b5a7 + sidekiq-cron (2.2.0) sha256=4de604412a733036130bd5f5fac12f31102f027c67aa21980b60c00eb2dfec41 + sidekiq-status (3.0.3) sha256=efd8d33417d79f3a86fdac094f8fb2c61afa72b792569797e95d83c4c8ad94dd + sidekiq-unique-jobs (8.0.10) sha256=d8abed98f863b2f830a75839e8325b892e72a2fda7cf335f10540382393c950c + simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 + simplecov-html (0.13.1) sha256=5dab0b7ee612e60e9887ad57693832fdf4695b4c0c859eaea5f95c18791ef10b + simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d + solargraph (0.53.4) sha256=a14c778bf96ed06e2e23438b35113acd5256233880e363cc11a75900a09a65d2 + sorted_set (1.0.3) sha256=4f2b8bee6e8c59cbd296228c0f1f81679357177a8b6859dcc2a99e86cce6372f + spreadsheet (1.3.4) sha256=0aefd6f3dfdc8b43528109f7fbd54db54f85ce5920429413d48305906bc59253 + sprockets (4.2.1) sha256=951b13dd2f2fcae840a7184722689a803e0ff9d2702d902bd844b196da773f97 + sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e + stringio (3.1.6) sha256=292c495d1657adfcdf0a32eecf12a60e6691317a500c3112ad3b2e31068274f5 + sys-filesystem (1.5.5) sha256=6f995890a734b9f0aa55df5e09d99adeb9fd1c288f2c4097269a1f8c95e15033 + tca_client (1.0.4) sha256=6d72702d0e4b02f4cec236a0f34f32b74fcc3072b29b3a42c486f646575b548f + tcp_timeout (0.1.1) sha256=9a289238e89acfc1bcbeaabae18b3f4e19ce30e9d38afc1320d8d0b9fa8c3239 + thor (1.3.2) sha256=eef0293b9e24158ccad7ab383ae83534b7ad4ed99c09f96f1a6b036550abbeda + tilt (2.6.0) sha256=263d748466e0d83e510aa1a2e2281eff547937f0ef06be33d3632721e255f76b + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + ttfunk (1.8.0) sha256=a7cbc7e489cc46e979dde04d34b5b9e4f5c8f1ee5fc6b1a7be39b829919d20ca + typhoeus (1.4.1) sha256=1c17db8364bd45ab302dc61e460173c3e69835896be88a3df07c206d5c55ef7c + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode (0.4.4.5) sha256=42f294bfc8e186d29da89d1f766071505a20a22776168a31bb3408e03fa7a9d7 + unicode-display_width (3.1.4) sha256=8caf2af1c0f2f07ec89ef9e18c7d88c2790e217c482bfc78aaa65eadd5415ac1 + unicode-emoji (4.0.4) sha256=2c2c4ef7f353e5809497126285a50b23056cc6e61b64433764a35eff6c36532a + uri (1.0.4) sha256=34485d137c079f8753a0ca1d883841a7ba2e5fae556e3c30c2aab0dde616344b + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0 + warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 + web-push (3.0.1) sha256=5b4dd2f2bba3bd8951da6416492fe920a6f203d14d3080f943c5d01c0cc4b18d + webmock (3.25.1) sha256=ab9d5d9353bcbe6322c83e1c60a7103988efc7b67cd72ffb9012629c3d396323 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + yard (0.9.45) sha256=52e211493f7cb8a3ebf7e104a25a1e73937a3103092545d34cb88fafebb3dc51 + yard-solargraph (0.1.0) sha256=a19a4619c942181a618fb9458970a9d2534cf7fda69fc43949629a7948a5930e + zeitwerk (2.7.2) sha256=842e067cb11eb923d747249badfb5fcdc9652d6f20a1f06453317920fdcd4673 + RUBY VERSION - ruby 3.4.2p28 + ruby 3.4.10p104 BUNDLED WITH 2.6.6 diff --git a/README.md b/README.md index e1207a2a90..e38ef711a6 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,43 @@ Doubtfire is a feedback-driven learning support system. ## Getting started +Ruby 3.4.10 is required; the version is specified in `.ruby-version`. + See [Doubtfire Deploy](https://github.com/doubtfire-lms/doubtfire-deploy) for instructions on deploying, and contributing, to the Doubtfire project. +The legacy root `docker-compose.yml` defaults to local database authentication. +Optional AAF development must use a dedicated non-production registration +supplied through an ignored `.env` file copied from `.env.example`. Any AAF +secret ever committed to Git must be treated as compromised and rotated by its +identity owner. + +Image publication is coordinated from the exact API/web revisions pinned by +`doubtfire-deploy` and its `production/publish-release.sh` release gate. The +legacy API image workflow is intentionally build-only and cannot publish a +tagged image independently of the cross-repository handover checks. + +The first-time tutorial reads the authenticated `GET /api/settings` rollout flag +and `GET /api/projects/history`. The history endpoint returns only +`{"hasProjects": true}` or `{"hasProjects": false}` for the authenticated user, +including projects in inactive units and withdrawn enrolments. It accepts no owner +selection and does not return project details. The ordinary `/api/projects` list +continues to exclude withdrawn enrolments, so it must not be used to establish +that an account has no prior project history. + ## Environment variables Doubtfire requires multiple environment variables that help define settings about the Doubtfire instance running. Whilst these will default to other values, you may want to override them in production. | Key | Description | Default | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| `TUTORIAL_ENABLED` | Enable the first-time tutorial in authenticated web settings. Set `1` to enable; unset, blank, `0`, `false` and non-numeric values (including `true`) disable it, matching the existing numeric feature flag parser. Restart API processes after changing the environment; recreate containers to load new environment values. No image rebuild is needed. | false | | `DF_AUTH_METHOD` | The authentication method you would like Doubtfire to use. Possible values are `database` for standard authentication with the database, `ldap` | `database` | | | for [LDAP](https://www.freebsd.org/doc/en/articles/ldap-auth/), `aaf` for [AAF Rapid Connect](https://rapid.aaf.edu.au/), or `SAML2` for [SAML2.0 auth](https://en.wikipedia.org/wiki/SAML_2.0). | | | `DF_STUDENT_WORK_DIR` | The directory to store uploaded student work for processing. | `student_work` | | `DF_ARCHIVE_DIR` | The directory to move archived unit files to, and access from. | `DF_STUDENT_WORK_DIR/archive` | | `DF_INSTITUTION_NAME` | The name of your institution running Doubtfire. | _Doubtfire University_ | | `DF_INSTITUTION_EMAIL_DOMAIN` | The email domain from which emails are sent to and from in your institution. | `doubtfire.com` | +| `DF_INSTITUTION_EMAIL_SENDER` | The SMTP-authorised From address used for event-notification email. It may include a display name. | `noreply@doubtfire.local` | | `DF_INSTITUTION_HOST` | The host running the Doubtfire instance. | `localhost:3000` | | `DF_COOKIE_DOMAIN` | The domain to be associated with secure cookies. | Attempts to read from host | | `DF_INSTITUTION_PRODUCT_NAME` | The name of the product (i.e. Doubtfire) at your institution. | _Doubtfire_ | @@ -49,8 +72,9 @@ Doubtfire requires multiple environment variables that help define settings abou | `DF_INSTITUTION_PLAGIARISM` | A statement clarifying the terms plagiarism and collusion. | Default statement provided | | `DF_INSTITUTION_SETTINGS_RB` | The path of the institution specific settings rb code - used to map student imports from institutional exports to a format understood by Doubtfire. | No default | | `DF_FFMPEG_PATH` | The path of to the ffmpeg binary for audio processing. | ffmpeg | -| `DF_REDIS_CACHE_URL` | The redis URL for rails used for development and production, ignored in the test env. | `redis://localhost:6379/0` | +| `DF_REDIS_CACHE_URL` | The preferred shared Redis URL for Rails caching and authentication throttling. Production and staging must set this or `DF_REDIS_SIDEKIQ_URL`; it is ignored in the test environment. | No production default | | `DF_REDIS_SIDEKIQ_URL` | The redis URL for sidekiq. A working redis server is **mandatory** for sidekiq in all environments. | `redis://localhost:6379/1` | +| `DF_IMPORT_STUDENTS_WEEKS_BEFORE`| How many weeks before a teaching period starts to import students. Deprecated alias: `DF_IMPORT_STUDENTS_WEEKS_BEFPRE`. | `1` | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | **Turn It In Integration** | | | | `TII_ENABLED` | Whether or not Turn It In integration is enabled. | 0 / false | @@ -142,8 +166,15 @@ To run unit tests, execute: $ rake test ``` -Unit tests are located in the `test` directory, where **model** tests are under -the `model` subdirectory and **API** tests are under the `api` subdirectory. +Code coverage is disabled during normal test runs to keep feedback fast. To +generate the SimpleCov report explicitly, run: + +```bash +$ COVERAGE=true rake test +``` + +Doubtfire API uses Minitest for testing, with model tests under `test/models` and API tests under `test/api`. +Run the test suite with `rake test`. Any **helpers** should be included in the `helpers` subdirectory and helper modules should be written under the `TestHelpers` module. diff --git a/Rakefile b/Rakefile old mode 100644 new mode 100755 diff --git a/app/api/activity_types_public_api.rb b/app/api/activity_types_public_api.rb index 5d08e11fbf..98eafa773d 100644 --- a/app/api/activity_types_public_api.rb +++ b/app/api/activity_types_public_api.rb @@ -1,13 +1,20 @@ require 'grape' class ActivityTypesPublicApi < Grape::API + helpers CollectionPaginationHelpers desc "Get an activity type details" get '/activity_types/:id' do present ActivityType.find(params[:id]), with: Entities::ActivityTypeEntity end desc 'Get all the activity types' + params do + optional :page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PAGE, allow_blank: false + optional :per_page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PER_PAGE, allow_blank: false + end get '/activity_types' do - present ActivityType.all, with: Entities::ActivityTypeEntity + result = paginate_collection(ActivityType.all) + + present result, with: Entities::ActivityTypeEntity end end diff --git a/app/api/campuses_public_api.rb b/app/api/campuses_public_api.rb index 9ec897edc0..b17d835a59 100644 --- a/app/api/campuses_public_api.rb +++ b/app/api/campuses_public_api.rb @@ -1,6 +1,7 @@ require 'grape' class CampusesPublicApi < Grape::API + helpers CollectionPaginationHelpers desc "Get a campus details" get '/campuses/:id' do campus = Campus.find(params[:id]) @@ -8,7 +9,13 @@ class CampusesPublicApi < Grape::API end desc 'Get all the Campuses' + params do + optional :page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PAGE, allow_blank: false + optional :per_page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PER_PAGE, allow_blank: false + end get '/campuses' do - present Campus.all, with: Entities::CampusEntity + result = paginate_collection(Campus.all) + + present result, with: Entities::CampusEntity end end diff --git a/app/api/entities/task_status_entity.rb b/app/api/entities/task_status_entity.rb new file mode 100644 index 0000000000..fc2550b69b --- /dev/null +++ b/app/api/entities/task_status_entity.rb @@ -0,0 +1,10 @@ +module Entities + class TaskStatusEntity < Grape::Entity + expose :id + expose :key do |task_status, _options| + TaskStatus.id_to_key(task_status.id) + end + expose :name + expose :description + end +end diff --git a/app/api/feedback/feedback_chip_api.rb b/app/api/feedback/feedback_chip_api.rb index 6b364ad47a..7f6160c85a 100644 --- a/app/api/feedback/feedback_chip_api.rb +++ b/app/api/feedback/feedback_chip_api.rb @@ -6,6 +6,7 @@ class FeedbackChipApi < Grape::API helpers MimeCheckHelpers helpers CsvHelper helpers FileHelper + helpers ContextModelHelpers before do authenticated? @@ -17,8 +18,7 @@ class FeedbackChipApi < Grape::API requires :context_id, type: Integer, desc: 'The ID of the context' end get '/:context_type_plural/:context_id/feedback_chips' do - context_type = params[:context_type_plural].singularize.camelize - context_model = context_type.classify.constantize.find(params[:context_id]) + context_model = context_model_for(params[:context_type_plural], params[:context_id]) unless authorise? current_user, context_model, :get_feedback_chips error!({ error: 'You are not authorised to view feedback chips in this context.' }, 403) @@ -137,16 +137,21 @@ class FeedbackChipApi < Grape::API nil end - desc 'Track usage of a feedback template chip by a tutor' + desc 'Track usage of a feedback template chip by the current user' params do requires :id, type: Integer, desc: 'The ID of the feedback template chip' - requires :tutor_id, type: Integer, desc: 'The ID of the tutor' end post '/feedback_template_chip/:id/track_usage' do chip = FeedbackTemplateChip.find(params[:id]) - tutor = Tutor.find(params[:tutor_id]) - chip.track_usage_by_tutor(tutor) + unless authorise? current_user, chip, :track_chip_usage + error!({ error: 'You are not authorised to track feedback chip usage.' }, 403) + end + + # Always record usage against the caller. Accepting a tutor id from the + # request would let any tutor log usage for any user, and looking that + # user up would reveal which user ids exist. + chip.track_usage_by(current_user) nil end @@ -157,8 +162,7 @@ class FeedbackChipApi < Grape::API end get '/:context_type_plural/:context_id/outcomes/:id/feedback_chips/csv' do # find context model dynamically - context_type = params[:context_type_plural].singularize.camelize - context_model = context_type.classify.constantize.find(params[:context_id]) + context_model = context_model_for(params[:context_type_plural], params[:context_id]) learning_outcome = LearningOutcome.find(params[:id]) unless authorise? current_user, context_model, :create_feedback_chips @@ -182,8 +186,7 @@ class FeedbackChipApi < Grape::API end get '/:context_type_plural/:context_id/feedback_chips/csv' do include_tlos = params[:includes_tlos] || false - context_type = params[:context_type_plural].singularize.camelize - context_model = context_type.classify.constantize.find(params[:context_id]) + context_model = context_model_for(params[:context_type_plural], params[:context_id]) unless authorise? current_user, context_model, :create_feedback_chips error!({ error: 'You are not authorised to download feedback chips in this context.' }, 403) @@ -210,8 +213,7 @@ class FeedbackChipApi < Grape::API # check mime is correct before uploading ensure_csv!(params[:file][:tempfile]) - context_type = params[:context_type_plural].singularize.camelize - context_model = context_type.classify.constantize.find(params[:context_id]) + context_model = context_model_for(params[:context_type_plural], params[:context_id]) # find context model dynamically learning_outcome = context_model.learning_outcomes.find(params[:id]) @@ -234,8 +236,8 @@ class FeedbackChipApi < Grape::API # check mime is correct before uploading ensure_csv!(params[:file][:tempfile]) - context_type = params[:context_type_plural].singularize.camelize - context_model = context_type.classify.constantize.find(params[:context_id]) + context_type = context_type_for(params[:context_type_plural]) + context_model = context_model_for(params[:context_type_plural], params[:context_id]) unless authorise? current_user, context_model, :create_feedback_chips error!({ error: "Not authorised to upload CSV of feedback chips for #{context_type}" }, 403) diff --git a/app/api/group_sets_api.rb b/app/api/group_sets_api.rb index eb78a85d4e..5e4a7fb950 100644 --- a/app/api/group_sets_api.rb +++ b/app/api/group_sets_api.rb @@ -4,6 +4,7 @@ # Allow GroupSets to be managed via the API # class GroupSetsApi < Grape::API + helpers CollectionPaginationHelpers helpers AuthenticationHelpers helpers AuthorisationHelpers helpers MimeCheckHelpers @@ -363,6 +364,10 @@ class GroupSetsApi < Grape::API end desc 'Get the members of a group' + params do + optional :page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PAGE, allow_blank: false + optional :per_page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PER_PAGE, allow_blank: false + end get '/units/:unit_id/group_sets/:group_set_id/groups/:group_id/members' do unit = Unit.find(params[:unit_id]) group_set = unit.group_sets.find(params[:group_set_id]) @@ -372,7 +377,9 @@ class GroupSetsApi < Grape::API error!({ error: 'Not authorised to get groups for this unit' }, 403) end - present grp.projects, with: Entities::ProjectEntity, only: [:student, :id, :target_grade], user: current_user + result = paginate_collection(grp.projects) + + present result, with: Entities::ProjectEntity, only: [:student, :id, :target_grade], user: current_user end desc 'Add a group member' diff --git a/app/api/task_statuses_api.rb b/app/api/task_statuses_api.rb new file mode 100644 index 0000000000..e90c9e985a --- /dev/null +++ b/app/api/task_statuses_api.rb @@ -0,0 +1,8 @@ +require 'grape' + +class TaskStatusesApi < Grape::API + desc 'Get all the task statuses' + get '/task_statuses' do + present TaskStatus.all.order(:id), with: Entities::TaskStatusEntity + end +end diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 411065f97e..7cf0ce9b8d 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -3,6 +3,7 @@ require 'entities/unit_entity' class UnitsApi < Grape::API + helpers CollectionPaginationHelpers helpers AuthenticationHelpers helpers AuthorisationHelpers helpers MimeCheckHelpers @@ -25,6 +26,8 @@ class UnitsApi < Grape::API desc 'Get units related to the current user for admin purposes' params do + optional :page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PAGE, allow_blank: false + optional :per_page, type: Integer, values: 1..CollectionPaginationHelpers::MAX_PER_PAGE, allow_blank: false optional :include_in_active, type: Boolean, desc: 'Include units that are not active' end get '/units' do @@ -37,6 +40,8 @@ class UnitsApi < Grape::API units = units.where('active = true') unless params[:include_in_active] + units = paginate_collection(units) + present units, with: Entities::UnitEntity, user: current_user, summary_only: true, in_unit: true end @@ -73,6 +78,7 @@ class UnitsApi < Grape::API optional :code, type: String optional :description, type: String optional :active, type: Boolean + optional :peer_progress_enabled, type: Boolean, desc: 'Enable anonymous peer progress for students in this unit' optional :teaching_period_id, type: Integer optional :start_date, type: Date optional :end_date, type: Date @@ -116,6 +122,7 @@ class UnitsApi < Grape::API :description, :start_date, :end_date, + :peer_progress_enabled, :teaching_period_id, :active, :main_convenor_id, @@ -454,6 +461,10 @@ class UnitsApi < Grape::API # Actually withdraw... response = unit.unenrol_users_from_csv(File.new(path)) + Rails.logger.info({ event: 'units.bulk_withdraw', user_id: current_user.id, unit_id: unit.id, + row_count: response.values.sum(&:length), withdrawn_count: response[:success].length, + project_ids: response[:success].pluck(:project_id), + ignored_count: response[:ignored].length, error_count: response[:errors].length }.to_json) present response, with: Grape::Presenters::Presenter end @@ -464,6 +475,8 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to download CSV of students enrolled in #{unit.code}" }, 403) end + Rails.logger.info({ event: 'units.csv_export', user_id: current_user.id, unit_id: unit.id }.to_json) + content_type 'application/octet-stream' header['Content-Disposition'] = "attachment; filename=#{unit.code}-Students.csv" header['Access-Control-Expose-Headers'] = 'Content-Disposition' @@ -659,6 +672,35 @@ class UnitsApi < Grape::API present job, with: Entities::SidekiqJobEntity end + desc 'Queue an on-demand plagiarism rescan for this unit' + params do + optional :task_definition_id, type: Integer, desc: 'Reserved for a future per-definition scan; the scan currently covers the whole unit' + end + post '/units/:id/similarity/scan' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :run_similarity_scan + error!({ error: "Not authorised to run a similarity scan for #{unit.code}" }, 403) + end + + # Reuse the 30-minute cooldown the snapshot capture endpoint above uses, so a + # convenor cannot hammer JPlag by holding the button. last_plagarism_scan is + # stamped when a scan finishes and defaults to the distant past, so the first + # scan is never blocked. + last_scan = unit.last_plagarism_scan + if last_scan.present? && last_scan > 30.minutes.ago + remaining_seconds = [(last_scan + 30.minutes - Time.zone.now).ceil, 0].max + remaining_minutes = [(remaining_seconds / 60.0).ceil, 1].max + error!({ error: "A similarity scan ran at #{last_scan.strftime('%H:%M')}. Please wait #{remaining_minutes} more minute(s) before starting another." }, 429) + end + + job_id = CheckUnitSimilarityJob.perform_async(unit.id, true, params[:task_definition_id]) + if job_id.nil? + error!({ error: 'A similarity scan is already queued or running for this unit.' }, 409) + end + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + desc 'Download stats related to the number of tasks assessed by each tutor' get '/csv/units/:id/tutor_assessments' do unit = Unit.find(params[:id]) diff --git a/app/helpers/authorisation_helpers.rb b/app/helpers/authorisation_helpers.rb index b27fd59024..410866bb17 100644 --- a/app/helpers/authorisation_helpers.rb +++ b/app/helpers/authorisation_helpers.rb @@ -43,18 +43,25 @@ def authorise?(user, object, action, perm_get_fn = method(:get_permission_hash), role_obj = object.role_for(user) - return false if role_obj.nil? + if role_obj.nil? + Rails.logger.warn "authorisation denied: #{action} on #{obj_class} for user #{user&.id}" + return false + end - # Attempt to get the unit role from a Unit context - unit_role = object&.unit_role_for(user) if object.respond_to?(:unit_role_for) + # Observer status cannot change an allowlisted permission, so avoid a unit + # role lookup for those hot-path reads (including plagiarism visibility). + unless OBSERVER_ONLY_PERMISSIONS.include?(action) + unit_role = object&.unit_role_for(user) if object.respond_to?(:unit_role_for) - # Attempt to get the unit role if object has a unit reference - if unit_role.nil? && object.respond_to?(:unit) - unit_role = object.unit.unit_role_for(user) - end + # Attempt to get the unit role if object has a unit reference + if unit_role.nil? && object.respond_to?(:unit) + unit_role = object.unit.unit_role_for(user) + end - if !unit_role.nil? && unit_role.observer_only && !OBSERVER_ONLY_PERMISSIONS.include?(action) - return false + if !unit_role.nil? && unit_role.observer_only + Rails.logger.warn "authorisation denied: #{action} on #{obj_class} for user #{user&.id}" + return false + end end role = role_obj.to_sym @@ -63,7 +70,9 @@ def authorise?(user, object, action, perm_get_fn = method(:get_permission_hash), # No permissions, default to false authorise, else check if the action # is in the permissions hash - perms.nil? ? false : perms.include?(action) + granted = perms.nil? ? false : perms.include?(action) + Rails.logger.warn "authorisation denied: #{action} on #{obj_class} for user #{user&.id}" unless granted + granted end module_function :get_permission_hash diff --git a/app/models/feedback/feedback_chip.rb b/app/models/feedback/feedback_chip.rb index deabbeccff..5124753e2d 100644 --- a/app/models/feedback/feedback_chip.rb +++ b/app/models/feedback/feedback_chip.rb @@ -33,12 +33,18 @@ class FeedbackChip < ApplicationRecord def self.permissions convenor_role_permissions = [ :update_chip, - :delete_feedback_chips + :delete_feedback_chips, + :track_chip_usage ] admin_role_permissions = [ :update_chip, - :delete_feedback_chips + :delete_feedback_chips, + :track_chip_usage + ] + + tutor_role_permissions = [ + :track_chip_usage ] nil_role_permissions = [] @@ -46,7 +52,7 @@ def self.permissions { convenor: convenor_role_permissions, admin: admin_role_permissions, - tutor: nil_role_permissions, + tutor: tutor_role_permissions, student: nil_role_permissions, auditor: nil_role_permissions, nil: nil_role_permissions @@ -55,8 +61,20 @@ def self.permissions delegate :role_for, to: :learning_outcome + # Lets authorise? find the unit role behind a unit or task chip, so + # observer-only staff are refused chip writes. Global chips have no unit. + def unit_role_for(user) + context = learning_outcome&.context + unit = if context.is_a?(Unit) + context + elsif context.is_a?(TaskDefinition) + context.unit + end + unit&.unit_role_for(user) + end + def track_usage_by(tutor) - analytics = chip_usage_analytics.find_or_initialize_by(tutor: tutor) + analytics = chip_usages.find_or_initialize_by(tutor: tutor) analytics.usage_count += 1 analytics.save end diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000000..efc0377492 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/config/application.rb b/config/application.rb index c4f3e6f3b7..1bdb4aff2b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -60,13 +60,24 @@ class Application < Rails::Application # Minimum time to wait before notifying a student about an unread failed overseer assessment config.overseer_student_notification_grace_period = ENV.fetch('OVERSEER_STUDENT_NOTIFICATION_GRACE_PERIOD_MINUTES', 30).to_i.minutes + # Parse a positive, bounded integer from the environment. Raises at boot on a + # value that is not an integer, is below 1, or is above the given maximum, so + # a misconfiguration is caught immediately rather than at first use. + def self.fetch_positive_integer_env(name, default:, max:) + value = Integer(ENV.fetch(name, default), exception: false) + unless value && value >= 1 && value <= max + raise "#{name} must be an integer between 1 and #{max}, got #{ENV[name].inspect}" + end + value + end + # Limit number of pdf generators to run at once - config.pdfgen_max_processes = ENV['DF_MAX_PDF_GEN_PROCESSES'] || 2 + config.pdfgen_max_processes = fetch_positive_integer_env('DF_MAX_PDF_GEN_PROCESSES', default: 2, max: 100) # Date range for auditors to view config.auditor_unit_access_years = ENV.fetch('DF_AUDITOR_UNIT_ACCESS_YEARS', 2).to_f * 1.year - config.student_import_weeks_before = ENV.fetch('DF_IMPORT_STUDENTS_WEEKS_BEFPRE', 1).to_f * 1.week + config.student_import_weeks_before = ENV.fetch('DF_IMPORT_STUDENTS_WEEKS_BEFORE') { ENV.fetch('DF_IMPORT_STUDENTS_WEEKS_BEFPRE', 1) }.to_f * 1.week def self.fetch_boolean_env(name) %w'true 1'.include?(ENV.fetch(name, 'false').downcase) @@ -140,6 +151,7 @@ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) config.institution = YAML.load_file(Rails.root.join('config/institution.yml').to_s).with_indifferent_access config.institution[:name] = ENV['DF_INSTITUTION_NAME'] if ENV['DF_INSTITUTION_NAME'] config.institution[:email_domain] = ENV['DF_INSTITUTION_EMAIL_DOMAIN'] if ENV['DF_INSTITUTION_EMAIL_DOMAIN'] + config.institution[:email_sender] = ENV['DF_INSTITUTION_EMAIL_SENDER'] if ENV['DF_INSTITUTION_EMAIL_SENDER'] config.institution[:host] = ENV['DF_INSTITUTION_HOST'] if ENV['DF_INSTITUTION_HOST'] config.institution[:cookie_domain] = ENV.fetch('DF_COOKIE_DOMAIN', URI.parse(Doubtfire::Application.config.institution[:host]).host) config.institution[:product_name] = ENV['DF_INSTITUTION_PRODUCT_NAME'] if ENV['DF_INSTITUTION_PRODUCT_NAME'] @@ -247,17 +259,24 @@ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) raise "Required keys are not set, check the following environment variables: \n " \ "key => variable set?\n " \ "DF_SECRET_KEY_BASE => #{!credentials.secret_key_base.nil?}\n " \ - "DF_SECRET_KEY_ATTR => #{!credentials.secret_key_base.nil?}\n " \ - "DF_SECRET_KEY_DEVISE => #{!credentials.secret_key_base.nil?}" + "DF_SECRET_KEY_ATTR => #{!credentials.secret_key_attr.nil?}\n " \ + "DF_SECRET_KEY_DEVISE => #{!credentials.secret_key_devise.nil?}" end # Localization config.i18n.enforce_available_locales = true # Ensure that auth tokens do not appear in log files config.filter_parameters += %i( + authToken auth_token + ltiToken + lti_token + ltik password password_confirmation + refresh_token + SAMLResponse + token ) # Grape Serialization @@ -287,7 +306,8 @@ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) config.middleware.insert_before Warden::Manager, Rack::Cors do allow do origins '*' - resource '*', headers: :any, methods: %i(get post put delete options) + resource '*', headers: :any, methods: %i(get post put delete options), + expose: %w(X-Total-Count X-Page X-Per-Page X-Total-Pages) end end @@ -307,6 +327,10 @@ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) end config.sm_instance = nil + + # Runtime rollout gate for the first-time tutorial; use 1 to enable. + config.tutorial_enabled = ENV['TUTORIAL_ENABLED'].present? && ENV['TUTORIAL_ENABLED'].to_s.downcase != "false" && ENV['TUTORIAL_ENABLED'].to_i != 0 + config.overseer_enabled = ENV['OVERSEER_ENABLED'].present? && ENV['OVERSEER_ENABLED'].to_s.downcase != "false" && ENV['OVERSEER_ENABLED'].to_i != 0 config.docker_config = { diff --git a/config/database.yml b/config/database.yml index 81ebb08640..c68aef2aa2 100644 --- a/config/database.yml +++ b/config/database.yml @@ -24,6 +24,8 @@ staging: database: <%= Rails.application.credentials.dig(:database, :staging, :database) || ENV['DF_STAGING_DB_DATABASE'] %> username: <%= Rails.application.credentials.dig(:database, :staging, :username) || ENV['DF_STAGING_DB_USERNAME'] %> password: <%= Rails.application.credentials.dig(:database, :staging, :password) || ENV['DF_STAGING_DB_PASSWORD'] %> + encoding: utf8mb4 + collation: utf8mb4_general_ci production: adapter: <%= Rails.application.credentials.dig(:database, :production, :adapter) || ENV['DF_PRODUCTION_DB_ADAPTER'] %> @@ -31,3 +33,5 @@ production: database: <%= Rails.application.credentials.dig(:database, :production, :database) || ENV['DF_PRODUCTION_DB_DATABASE'] %> username: <%= Rails.application.credentials.dig(:database, :production, :username) || ENV['DF_PRODUCTION_DB_USERNAME'] %> password: <%= Rails.application.credentials.dig(:database, :production, :password) || ENV['DF_PRODUCTION_DB_PASSWORD'] %> + encoding: utf8mb4 + collation: utf8mb4_general_ci diff --git a/config/routes.rb b/config/routes.rb index ea52a79000..f361894f56 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,4 +11,5 @@ mount Sidekiq::Web => "/sidekiq" # mount Sidekiq::Web in your Rails app get "health" => "rails/health#show", as: :rails_health_check + get "readiness" => "readiness#show", as: :readiness_check end diff --git a/config/schedule.rb b/config/schedule.rb deleted file mode 100644 index 93f33fc11a..0000000000 --- a/config/schedule.rb +++ /dev/null @@ -1,5 +0,0 @@ -set :output, "#{path}/log/cron.log" - -every 1.day, at: '3:00 am' do - rake 'db:update_temporal' -end diff --git a/db/migrate/20260922010000_create_courseflow_planner.rb b/db/migrate/20260922010000_create_courseflow_planner.rb new file mode 100644 index 0000000000..fc80eb74e9 --- /dev/null +++ b/db/migrate/20260922010000_create_courseflow_planner.rb @@ -0,0 +1,23 @@ +class CreateCourseflowPlanner < ActiveRecord::Migration[8.0] + def change + create_table :courseflow_courses do |t| + t.string :code, limit: 40, null: false + t.string :name, limit: 200, null: false + t.string :version, limit: 40, null: false + t.integer :elective_count, null: false + t.json :units, null: false + t.timestamps + t.index [:code, :version], unique: true + end + + create_table :courseflow_maps do |t| + t.references :user, null: false, foreign_key: { on_delete: :cascade } + t.references :course, null: false, foreign_key: { to_table: :courseflow_courses } + t.string :name, limit: 200, null: false + t.json :periods, null: false + t.json :slots, null: false + t.integer :lock_version, default: 0, null: false + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b8ec5659b3..bd9e7d60b0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_09_014859) do +ActiveRecord::Schema[8.0].define(version: 2026_09_22_010000) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -20,6 +20,27 @@ t.index ["name"], name: "index_activity_types_on_name", unique: true end + create_table "additional_notification_email_audits", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "event", limit: 64, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "event", "created_at"], name: "idx_additional_email_audits_user_event_time" + t.index ["user_id"], name: "index_additional_notification_email_audits_on_user_id" + end + + create_table "additional_notification_emails", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "email", limit: 254, null: false + t.integer "verification_version", default: 0, null: false + t.datetime "verification_sent_at" + t.datetime "verification_expires_at" + t.datetime "verified_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_additional_notification_emails_on_user_id", unique: true + end + create_table "auth_tokens", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.datetime "auth_token_expiry", null: false t.bigint "user_id" @@ -159,6 +180,44 @@ t.index ["unit_id"], name: "index_communication_sets_on_unit_id" end + create_table "consumed_lti_tokens", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.string "jti", null: false + t.bigint "user_id", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_consumed_lti_tokens_on_expires_at" + t.index ["jti"], name: "index_consumed_lti_tokens_on_jti", unique: true + t.index ["user_id"], name: "index_consumed_lti_tokens_on_user_id" + end + + create_table "courseflow_courses", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.string "code", limit: 40, null: false + t.string "name", limit: 200, null: false + t.string "version", limit: 40, null: false + t.integer "elective_count", null: false + t.text "units", size: :long, null: false, collation: "utf8mb4_bin" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["code", "version"], name: "index_courseflow_courses_on_code_and_version", unique: true + t.check_constraint "json_valid(`units`)", name: "units" + end + + create_table "courseflow_maps", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "course_id", null: false + t.string "name", limit: 200, null: false + t.text "periods", size: :long, null: false, collation: "utf8mb4_bin" + t.text "slots", size: :long, null: false, collation: "utf8mb4_bin" + t.integer "lock_version", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["course_id"], name: "index_courseflow_maps_on_course_id" + t.index ["user_id"], name: "index_courseflow_maps_on_user_id" + t.check_constraint "json_valid(`periods`)", name: "periods" + t.check_constraint "json_valid(`slots`)", name: "slots" + end + create_table "d2l_assessment_mappings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id", null: false t.string "org_unit_id" @@ -340,6 +399,32 @@ t.index ["task_id"], name: "index_moderated_tasks_on_task_id" end + create_table "notifications", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "notification_type", null: false + t.text "message", null: false + t.string "link" + t.datetime "read_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "event", null: false + t.string "dedupe_key", limit: 191 + t.datetime "delivered_at" + t.string "notifiable_type" + t.bigint "notifiable_id" + t.string "email_delivery_state", default: "untracked", null: false + t.integer "email_delivery_attempts", default: 0, null: false + t.datetime "email_delivered_at" + t.string "email_delivery_error_class" + t.index ["email_delivery_state"], name: "index_notifications_on_email_delivery_state" + t.index ["notifiable_type", "notifiable_id"], name: "index_notifications_on_notifiable_type_and_notifiable_id" + t.index ["user_id", "created_at"], name: "index_notifications_on_recipient_rate_window" + t.index ["user_id", "dedupe_key"], name: "index_notifications_on_user_and_dedupe_key", unique: true + t.index ["user_id", "event"], name: "index_notifications_on_user_id_and_event" + t.index ["user_id", "read_at"], name: "index_notifications_on_user_id_and_read_at" + t.index ["user_id"], name: "index_notifications_on_user_id" + end + create_table "overflow_task_claim_logs", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id", null: false t.bigint "task_id", null: false @@ -443,6 +528,23 @@ t.index ["task_definition_id"], name: "index_overseer_steps_on_task_definition_id" end + create_table "peer_progress_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.bigint "task_definition_id", null: false + t.integer "target_grade", null: false + t.decimal "submitted_percentage", precision: 5, scale: 2 + t.integer "cohort_size", null: false + t.datetime "calculated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "submitted_count" + t.text "status_counts", size: :long, collation: "utf8mb4_bin" + t.index ["task_definition_id"], name: "index_peer_progress_snapshots_on_task_definition_id" + t.index ["unit_id", "task_definition_id", "target_grade"], name: "idx_peer_progress_unit_task_grade", unique: true + t.index ["unit_id"], name: "index_peer_progress_snapshots_on_unit_id" + t.check_constraint "json_valid(`status_counts`)", name: "status_counts" + end + create_table "projects", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id" t.string "project_role" @@ -467,6 +569,7 @@ t.integer "spec_con_days", default: 0, null: false t.bigint "assessor_id" t.datetime "portfolio_submission_date" + t.datetime "target_grade_changed_at", default: -> { "current_timestamp(6)" }, null: false t.index ["assessor_id"], name: "index_projects_on_assessor_id" t.index ["campus_id"], name: "index_projects_on_campus_id" t.index ["enrolled"], name: "index_projects_on_enrolled" @@ -475,6 +578,17 @@ t.index ["user_id"], name: "index_projects_on_user_id" end + create_table "push_subscriptions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "endpoint", limit: 500, null: false + t.string "p256dh", null: false + t.string "auth", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true + t.index ["user_id"], name: "index_push_subscriptions_on_user_id" + end + create_table "roles", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name" t.text "description" @@ -542,6 +656,10 @@ t.bigint "reply_to_id" t.bigint "commentable_id" t.string "commentable_type" + t.string "attachment_original_filename" + t.string "attachment_content_type" + t.bigint "attachment_byte_size" + t.string "client_request_id" t.index ["assessor_id"], name: "index_task_comments_on_assessor_id" t.index ["commentable_type", "commentable_id"], name: "index_task_comments_on_commentable_type_and_commentable_id" t.index ["discussion_comment_id"], name: "index_task_comments_on_discussion_comment_id" @@ -549,6 +667,7 @@ t.index ["reply_to_id"], name: "index_task_comments_on_reply_to_id" t.index ["task_id"], name: "index_task_comments_on_task_id" t.index ["task_status_id"], name: "index_task_comments_on_task_status_id" + t.index ["user_id", "task_id", "client_request_id"], name: "idx_task_comments_user_task_client_request", unique: true t.index ["user_id"], name: "index_task_comments_on_user_id" end @@ -606,10 +725,16 @@ t.boolean "use_resources_for_jplag_base_code", default: false, null: false t.boolean "lock_assessments_to_tutorial_stream", default: false, null: false t.boolean "requires_discussion", default: false, null: false + t.datetime "new_task_notifications_from", default: -> { "utc_timestamp()" } + t.boolean "resubmission_extensions_enabled", default: true, null: false + t.datetime "resubmission_extensions_changed_at" + t.bigint "resubmission_extensions_changed_by_id" t.index ["abbreviation", "unit_id"], name: "index_task_definitions_on_abbreviation_and_unit_id", unique: true t.index ["group_set_id"], name: "index_task_definitions_on_group_set_id" t.index ["name", "unit_id"], name: "index_task_definitions_on_name_and_unit_id", unique: true + t.index ["new_task_notifications_from"], name: "index_task_definitions_on_new_task_notifications_from" t.index ["overseer_image_id"], name: "index_task_definitions_on_overseer_image_id" + t.index ["resubmission_extensions_changed_by_id"], name: "idx_on_resubmission_extensions_changed_by_id_fd949ce601" t.index ["tutorial_stream_id"], name: "index_task_definitions_on_tutorial_stream_id" t.index ["unit_id"], name: "index_task_definitions_on_unit_id" end @@ -702,6 +827,15 @@ t.datetime "target_start_date" t.datetime "target_due_date" t.datetime "last_tutor_feedback_at" + t.string "submission_processing_state" + t.datetime "submission_processing_started_at" + t.datetime "submission_processing_finished_at" + t.string "submission_processing_error_code" + t.integer "submission_processing_attempts", default: 0, null: false + t.string "submission_processing_mode" + t.bigint "submission_processing_user_id" + t.boolean "submission_processing_test_submission", default: false, null: false + t.boolean "submission_processing_accepted_tii_eula", default: false, null: false t.index ["group_submission_id"], name: "index_tasks_on_group_submission_id" t.index ["project_id", "task_definition_id"], name: "tasks_uniq_proj_task_def", unique: true t.index ["project_id"], name: "index_tasks_on_project_id" @@ -720,6 +854,19 @@ t.index ["period", "year"], name: "index_teaching_periods_on_period_and_year", unique: true end + create_table "teams_announcement_sync_states", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.string "mapping_key", limit: 64, null: false + t.string "status", default: "pending", null: false + t.datetime "last_attempt_at" + t.datetime "last_succeeded_at" + t.datetime "next_attempt_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["mapping_key"], name: "index_teams_announcement_sync_states_on_mapping_key", unique: true + t.index ["unit_id"], name: "index_teams_announcement_sync_states_on_unit_id" + end + create_table "test_attempts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "task_id" t.datetime "attempted_time", null: false @@ -852,6 +999,56 @@ t.index ["unit_role_id"], name: "index_tutorials_on_unit_role_id" end + create_table "unit_announcements", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.bigint "author_id" + t.string "title", limit: 200, null: false + t.text "body", null: false + t.string "source_url", limit: 2048 + t.boolean "pinned", default: false, null: false + t.datetime "published_at" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "source_provider", default: "manual", null: false + t.string "external_source_key", limit: 64 + t.string "source_mapping_key", limit: 64 + t.string "source_channel_key", limit: 64 + t.string "external_message_id", limit: 128 + t.datetime "source_updated_at" + t.datetime "source_imported_at" + t.datetime "source_checked_at" + t.index ["author_id"], name: "index_unit_announcements_on_author_id" + t.index ["source_mapping_key", "source_checked_at"], name: "index_announcements_source_scan" + t.index ["unit_id", "external_source_key"], name: "index_announcements_external_source", unique: true + t.index ["unit_id", "published_at"], name: "index_unit_announcements_on_unit_id_and_published_at" + t.index ["unit_id"], name: "index_unit_announcements_on_unit_id" + end + + create_table "unit_learning_sessions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.bigint "author_id" + t.string "title", limit: 200, null: false + t.text "description" + t.string "kind", default: "helphub", null: false + t.datetime "start_at", null: false + t.datetime "end_at", null: false + t.string "timezone", default: "Australia/Melbourne", null: false + t.string "location", limit: 300 + t.string "join_url", limit: 2048 + t.string "source_url", limit: 2048 + t.boolean "published", default: false, null: false + t.boolean "cancelled", default: false, null: false + t.string "recurrence", default: "none", null: false + t.date "recurrence_until" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "lock_version", default: 0, null: false + t.index ["author_id"], name: "index_unit_learning_sessions_on_author_id" + t.index ["unit_id", "published", "start_at"], name: "index_unit_sessions_for_feed" + t.index ["unit_id"], name: "index_unit_learning_sessions_on_unit_id" + end + create_table "unit_roles", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "user_id" t.bigint "tutorial_id" @@ -901,6 +1098,7 @@ t.integer "feedback_overflow_threshold_days", default: 7 t.boolean "enforce_feedback_before_discussed_in_class", default: false, null: false t.text "grade_values", size: :long, collation: "utf8mb4_bin" + t.boolean "peer_progress_enabled", default: false, null: false t.index ["draft_task_definition_id"], name: "index_units_on_draft_task_definition_id" t.index ["main_convenor_id"], name: "index_units_on_main_convenor_id" t.index ["overseer_image_id"], name: "index_units_on_overseer_image_id" @@ -956,6 +1154,9 @@ t.string "tii_eula_version" t.datetime "tii_eula_date" t.boolean "tii_eula_version_confirmed", default: false, null: false + t.boolean "display_peer_progress", default: true, null: false + t.string "theme_preference" + t.datetime "theme_preference_updated_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["login_id"], name: "index_users_on_login_id", unique: true t.index ["role_id"], name: "index_users_on_role_id" @@ -981,16 +1182,30 @@ t.string "reminder_unit" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "include_learning_sessions", default: false, null: false t.index ["guid"], name: "index_webcals_on_guid", unique: true t.index ["user_id"], name: "index_webcals_on_user_id", unique: true end + add_foreign_key "additional_notification_email_audits", "users" + add_foreign_key "additional_notification_emails", "users" add_foreign_key "chip_usages", "feedback_chips" add_foreign_key "chip_usages", "users", column: "tutor_id" + add_foreign_key "consumed_lti_tokens", "users" + add_foreign_key "courseflow_maps", "courseflow_courses", column: "course_id" + add_foreign_key "courseflow_maps", "users", on_delete: :cascade add_foreign_key "feedback_chips", "feedback_chips", column: "parent_chip_id" add_foreign_key "feedback_chips", "learning_outcomes" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "source_id" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "target_id" + add_foreign_key "notifications", "users" + add_foreign_key "push_subscriptions", "users" + add_foreign_key "task_definitions", "users", column: "resubmission_extensions_changed_by_id", on_delete: :nullify + add_foreign_key "teams_announcement_sync_states", "units" + add_foreign_key "unit_announcements", "units" + add_foreign_key "unit_announcements", "users", column: "author_id", on_delete: :nullify + add_foreign_key "unit_learning_sessions", "units" + add_foreign_key "unit_learning_sessions", "users", column: "author_id", on_delete: :nullify add_foreign_key "user_oauth_states", "users" add_foreign_key "user_oauth_tokens", "users" end diff --git a/dependabot.yml b/dependabot.yml deleted file mode 100644 index 0f96f8de94..0000000000 --- a/dependabot.yml +++ /dev/null @@ -1,9 +0,0 @@ -# Set update schedule for GitHub Actions - -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - # Check for updates to GitHub Actions every week - interval: "weekly" diff --git a/docker-bake.ci.hcl b/docker-bake.ci.hcl new file mode 100644 index 0000000000..edb9153290 --- /dev/null +++ b/docker-bake.ci.hcl @@ -0,0 +1,40 @@ +group "default" { + targets = ["api"] +} + +target "api" { + context = "." + dockerfile = "Dockerfile" + target = "ci" + tags = ["doubtfire-api-ci:local"] + cache-from = ["type=gha,scope=doubtfire-api"] +} + +target "api-cache-writer" { + inherits = ["api"] + cache-to = ["type=gha,mode=max,scope=doubtfire-api"] +} + +target "texlive" { + context = "." + dockerfile = "texlive.Dockerfile" + tags = ["doubtfire-texlive-development:local"] + cache-from = ["type=gha,scope=texlive"] +} + +target "texlive-cache-writer" { + inherits = ["texlive"] + cache-to = ["type=gha,mode=max,scope=texlive"] +} + +target "jplag" { + context = "." + dockerfile = "jplag.Dockerfile" + tags = ["doubtfire-jplag-development:local"] + cache-from = ["type=gha,scope=jplag"] +} + +target "jplag-cache-writer" { + inherits = ["jplag"] + cache-to = ["type=gha,mode=max,scope=jplag"] +} diff --git a/docker-compose.yml b/docker-compose.yml index 3987c71a7d..f659b9930d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: df-api: container_name: df-api @@ -25,15 +24,16 @@ services: DF_SECRET_KEY_ATTR: test-secret-key-test-secret-key! DF_SECRET_KEY_DEVISE: test-secret-key-test-secret-key! - # Authentication method - can set to AAF or ldap - DF_AUTH_METHOD: database - DF_AAF_ISSUER_URL: https://rapid.test.aaf.edu.au - DF_AAF_AUDIENCE_URL: http://localhost:3000 - DF_AAF_CALLBACK_URL: http://localhost:3000/api/auth/jwt - DF_AAF_IDENTITY_PROVIDER_URL: https://signon-uat.deakin.edu.au/idp/shibboleth - DF_AAF_UNIQUE_URL: https://rapid.test.aaf.edu.au/jwt/authnrequest/research/Ag4EJJhjf0zXHqlKvKZEbg - DF_AAF_AUTH_SIGNOUT_URL: https://sync-uat.deakin.edu.au/auth/logout - DF_SECRET_KEY_AAF: v4~LMFLzzwRGZdju\5QBa@FiHIN9 + # Database authentication is the safe local default. Optional AAF values + # must come from an ignored .env file and use a dedicated registration. + DF_AUTH_METHOD: ${DF_AUTH_METHOD:-database} + DF_AAF_ISSUER_URL: ${DF_AAF_ISSUER_URL:-} + DF_AAF_AUDIENCE_URL: ${DF_AAF_AUDIENCE_URL:-http://localhost:3000} + DF_AAF_CALLBACK_URL: ${DF_AAF_CALLBACK_URL:-http://localhost:3000/api/auth/jwt} + DF_AAF_IDENTITY_PROVIDER_URL: ${DF_AAF_IDENTITY_PROVIDER_URL:-} + DF_AAF_UNIQUE_URL: ${DF_AAF_UNIQUE_URL:-} + DF_AAF_AUTH_SIGNOUT_URL: ${DF_AAF_AUTH_SIGNOUT_URL:-} + DF_SECRET_KEY_AAF: ${DF_SECRET_KEY_AAF:-} # Database settings - for development env DF_DEV_DB_ADAPTER: mysql2 diff --git a/docs/README_FOR_APP b/docs/README_FOR_APP deleted file mode 100644 index fe41f5cc24..0000000000 --- a/docs/README_FOR_APP +++ /dev/null @@ -1,2 +0,0 @@ -Use this README file to introduce your application and point to useful places in the API for learning more. -Run "rake doc:app" to generate API documentation for your models, controllers, helpers, and libraries. diff --git a/docs/courseflow/README.md b/docs/courseflow/README.md new file mode 100644 index 0000000000..0c0377c8ac --- /dev/null +++ b/docs/courseflow/README.md @@ -0,0 +1,121 @@ +# Course Flow planning API + +Course Flow supports private, saved study plans against an explicitly configured, +versioned catalog. It does not infer a curriculum from teaching units, enrolments, +or historical unit IDs. A completed **planning check** is not a degree audit, +admission decision, or guarantee of enrolment or future availability. + +Deploy the migration before the companion frontend. No catalogs are automatically +seeded in production. An empty catalog returns `[]`; administrators must import +approved curriculum data before students can create plans. + +## Administrative catalog import + +An operator with server/database access can import one JSON catalog atomically: + +```sh +bundle exec rails db:migrate +bundle exec rake 'courseflow:import[path/to/approved-catalog.json]' +``` + +For a disposable test/demo environment only: + +```sh +bundle exec rake 'courseflow:import[docs/courseflow/sample-catalog.json]' +``` + +The included `DEMO-CF`, version `QA-2026`, is fictional test data and represents +no actual qualification or institution. Importing it is an explicit operator +action, never part of application startup or production seeds. + +The JSON object has exactly `code`, `name`, `version`, `elective_count`, and +`units`. Codes are uppercase ASCII letters/digits, `_`, or `-`, starting with a +letter/digit. Course codes and versions are at most 40 characters; names at most +200. The file limit is 1 MiB. Each of 1–240 units has exactly: + +```json +{"code":"DEMO102","name":"Demo follow-on study","required":true,"prerequisites":["DEMO101"],"offered_trimesters":[2,3]} +``` + +Unit codes are at most 20 characters and unique within the catalog. +`required` is a JSON boolean. `prerequisites` contains unique existing codes; +cycles are rejected. Every listed prerequisite must be planned in an earlier +year/trimester. `offered_trimesters` is a nonempty unique subset of integers +`1`, `2`, `3`. `elective_count` is a nonnegative JSON integer specifying the +exact number of non-required units needed. Impossible counts, including required +units whose transitive optional prerequisites exceed the elective allowance, +are rejected. Required prerequisite chains longer than 60 study periods are +rejected. Import validation does not prove that every combination of elective +choices and study periods is feasible; the planner evaluates the chosen plan. + +A `(code, version)` is immutable from its first import. An identical import is +idempotent; changed data must use a new version. Existing plans therefore retain +their original rules without a race between catalog editing and saving. There +is no public catalog mutation API. Catalog deletion is restricted while maps +reference it. Future rule types (credit points, substitutions, transfer credit, +corequisites, majors, exclusions, actual timetable capacity) need an explicit +schema and validator change; they must not be represented as supported checks. + +## Authenticated endpoints + +All paths below start with `/api/courseflow` and use the application's existing +authentication headers. All responses are `Cache-Control: private, no-store`. + +| Method | Path | Result | +| --- | --- | --- | +| GET | `/courses` | All configured catalogs, including units and rules | +| GET | `/courses/:id` | One catalog | +| GET | `/maps` | Current user's complete maps, newest updated first | +| GET | `/maps/:id` | Current user's complete map | +| POST | `/maps` | Create private map, HTTP 201 | +| PUT | `/maps/:id` | Atomically replace complete plan | +| DELETE | `/maps/:id?lock_version=N` | Delete matching version, HTTP 204 | + +POST takes exactly these JSON fields: + +```json +{ + "course_id": 1, + "name": "My plan", + "periods": [{"year":2026,"trimester":1},{"year":2026,"trimester":2}], + "slots": [{"unit_code":"DEMO101","year":2026,"trimester":1,"position":1}] +} +``` + +The course ID is the ID returned by the catalog API, never a fixed ID. PUT +requires those same fields plus the `lock_version` from the last response. +The owner derives exclusively from authentication; client `user_id` is rejected. +Course and owner cannot change on an existing map. Other users' maps return 404 +for reads, updates, and deletes, including requests by staff and administrators. + +Names are nonblank strings of at most 200 characters. Periods are explicit so +empty trimesters survive reload: 1–60 unique `{year, trimester}` objects, +integer year 2000–2200, trimester 1–3. Slots are at most 240 objects with exactly +`unit_code`, `year`, `trimester`, `position` (integer 1–4). Every slot belongs to +a declared period and the chosen catalog. Duplicate unit codes and occupied +positions are invalid. Numeric strings, floats, booleans, extra fields, and +malformed structures are rejected instead of coerced. + +A map response contains `id`, `course_id`, `name`, `lock_version`, `periods`, +`slots`, `issues`, `complete`, and ISO 8601 `updated_at`. `issues` contains +`{code, message, unit_code?}` with codes `missing_required`, `elective_count`, +`prerequisite`, and `unavailable_trimester`. Incomplete plans can be saved; +`complete` means only that these configured planning checks return no issues. + +Shape errors, malformed JSON and unknown course/unit IDs return 422 with `error` +and optional `details`. +Authentication failures use the application's existing 419 response. Missing or +unowned map IDs return 404. Stale updates/deletes return 409: the client must +retain unsaved work and explicitly reload before retrying. A stale request +never overwrites or partially removes the saved plan. Periods and slots are +stored together in one row with optimistic locking and foreign keys. + +## Validation + +```sh +bundle exec rails test test/models/courseflow_test.rb test/api/courseflow_api_test.rb +``` + +Tests cover catalog validation and immutability, owner isolation across roles, +authentication, atomic invalid/stale writes, explicit empty periods, strict JSON +types and limits, planning checks, save/reload and delete conflict handling. diff --git a/docs/courseflow/sample-catalog.json b/docs/courseflow/sample-catalog.json new file mode 100644 index 0000000000..dce64d4f84 --- /dev/null +++ b/docs/courseflow/sample-catalog.json @@ -0,0 +1,12 @@ +{ + "code": "DEMO-CF", + "name": "Demonstration course — fictional planning rules", + "version": "QA-2026", + "elective_count": 1, + "units": [ + {"code": "DEMO101", "name": "Demo foundations", "required": true, "prerequisites": [], "offered_trimesters": [1, 2, 3]}, + {"code": "DEMO102", "name": "Demo follow-on study", "required": true, "prerequisites": ["DEMO101"], "offered_trimesters": [2, 3]}, + {"code": "DEMO201", "name": "Demo elective A", "required": false, "prerequisites": [], "offered_trimesters": [1, 2, 3]}, + {"code": "DEMO202", "name": "Demo elective B", "required": false, "prerequisites": [], "offered_trimesters": [3]} + ] +} diff --git a/jplag.Dockerfile b/jplag.Dockerfile index 1fcf747ce6..f12473375f 100644 --- a/jplag.Dockerfile +++ b/jplag.Dockerfile @@ -1,11 +1,13 @@ -FROM alpine:3.23.3 +FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 -ENV JPLAG_VERSION=6.3.0 +ENV JPLAG_VERSION=6.3.0 \ + JPLAG_SHA256=5f2c21e8b88ed77134effcb3a5a3ab13d188f6a3e16d401387f7479e92db9aa2 WORKDIR /jplag RUN apk update && \ apk add --no-cache bash openjdk25-jdk wget && \ - wget -O jplag-jar-with-dependencies.jar \ - https://github.com/jplag/JPlag/releases/download/v$JPLAG_VERSION/jplag-$JPLAG_VERSION-jar-with-dependencies.jar + wget --https-only -O jplag-jar-with-dependencies.jar \ + "https://github.com/jplag/JPlag/releases/download/v${JPLAG_VERSION}/jplag-${JPLAG_VERSION}-jar-with-dependencies.jar" && \ + echo "${JPLAG_SHA256} jplag-jar-with-dependencies.jar" | sha256sum -c - CMD ["sh", "-c", "sleep infinity"] diff --git a/lib/shell/pdfgen_entry_point.sh b/lib/shell/pdfgen_entry_point.sh index c2f4a856ef..bf21675396 100755 --- a/lib/shell/pdfgen_entry_point.sh +++ b/lib/shell/pdfgen_entry_point.sh @@ -1,22 +1,23 @@ #!/bin/bash +set -euo pipefail + # Start the run once job. echo "Pdfgen docker container has been started" # Setup new aliases newaliases -# Save the docker user environment -declare -p | grep -Ev 'BASHOPTS|BASH_VERSINFO|EUID|PPID|SHELLOPTS|UID' > /container.env -cat /container.env +# Save only the environment required when Rails jobs run under cron. The file +# contains secrets needed to boot the application, so its writer keeps it +# private and this entry point must never print it. +/doubtfire/lib/shell/write_cron_environment.sh /container.env # Ensure log is present touch /var/log/cron.log -sleep 1 - # Setup crontab - clear then load with file -crontab -r +crontab -r 2>/dev/null || true crontab /etc/cron.d/container_cronjob echo "RESET CRONTAB" >> /var/log/cron.log @@ -24,15 +25,18 @@ echo "RESET CRONTAB" >> /var/log/cron.log # Setup msmptrc if [ -f "/shared-files/msmtprc" ]; then echo "Copying msmtprc file from shared-files" - cp -f /shared-files/msmtprc /etc; + install -o root -g root -m 0600 /shared-files/msmtprc /etc/msmtprc else echo "msmtprc file not found in shared-files, using default configuration" fi +# Ensure existing mail settings are accessible only by root. +if [ -f /etc/msmtprc ]; then + chown root:root /etc/msmtprc + chmod 0600 /etc/msmtprc +fi -# Ensure mail settings are accessible only by root -chown root:root /etc/msmtprc -chmod 600 /etc/msmtprc - -# Run cron and follow log -chmod 644 /etc/cron.d/container_cronjob && cron -f && tail -f /var/log/cron.log > /proc/1/fd/1 2>/proc/1/fd/2 +# Make cron PID 1 so Docker stop signals reach it directly. cron -f does not +# return while healthy, so the old trailing tail command was unreachable. +chmod 0644 /etc/cron.d/container_cronjob +exec cron -f diff --git a/lib/tasks/courseflow.rake b/lib/tasks/courseflow.rake new file mode 100644 index 0000000000..18c63be39d --- /dev/null +++ b/lib/tasks/courseflow.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +namespace :courseflow do + desc 'Import a versioned planning catalog from JSON: courseflow:import[path]' + task :import, [:path] => :environment do |_task, args| + abort 'Usage: bundle exec rake "courseflow:import[path/to/catalog.json]"' if args[:path].blank? + + course = Courseflow::CatalogImporter.import_file!(args[:path]) + puts "Imported planning catalog #{course.code} #{course.version} (id #{course.id})" + rescue JSON::ParserError, ArgumentError, SystemCallError, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e + abort "Catalog import failed: #{e.message}" + end +end diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake index d7053b6e2b..5664ed616f 100644 --- a/lib/tasks/maintenance.rake +++ b/lib/tasks/maintenance.rake @@ -10,9 +10,10 @@ namespace :maintenance do return true if matcher.call(payload['class'], payload['args']) end - # TODO: We may need to iterate through each queue when we implement parallel sidekiq jobs - Sidekiq::Queue.new("default").each do |job| - return true if matcher.call(job.klass, job.args) + %w[submissions default].each do |queue_name| + Sidekiq::Queue.new(queue_name).each do |job| + return true if matcher.call(job.klass, job.args) + end end false @@ -206,11 +207,17 @@ namespace :maintenance do .find_each(&:destroy!) AuthToken.destroy_old_tokens + ConsumedLtiToken.destroy_expired_tokens clear_abandoned_submissions! clear_abandoned_submission_history_markers! clear_abandoned_overseer_assessments! end + desc 'Remove the record of LTI tokens that have passed their expiry' + task clear_expired_lti_tokens: [:environment] do + ConsumedLtiToken.destroy_expired_tokens + end + desc 'Clear abandoned in-process submission folders and notify affected users' task clear_abandoned_submissions: [:environment] do clear_abandoned_submissions! @@ -264,7 +271,11 @@ namespace :maintenance do end puts "Removing old portfolio PDFs" - `find #{FileHelper.root_portfolio_dir} -name "*pdf.old" -exec rm {} \;` + Dir.glob(File.join(FileHelper.root_portfolio_dir, '**', '*pdf.old')).each do |old_pdf| + FileUtils.rm(old_pdf) + rescue StandardError => e + puts "Could not remove #{old_pdf}: #{e.message}" + end end end # rubocop:enable Metrics/BlockLength diff --git a/lib/tasks/populate.rake b/lib/tasks/populate.rake index 92103a6ffd..3bde93359d 100644 --- a/lib/tasks/populate.rake +++ b/lib/tasks/populate.rake @@ -152,9 +152,9 @@ namespace :db do # 1 to 3 case rand(1..100) - when 0..60 - task.assess tatus.working_on_it, tutor, Time.zone.now - when 60..75 + when 1..60 + task.assess TaskStatus.working_on_it, tutor, Time.zone.now + when 61..75 task.assess TaskStatus.need_help, tutor, Time.zone.now pdf_path = task.final_pdf_path diff --git a/script/benchmark_notifications.rb b/script/benchmark_notifications.rb new file mode 100644 index 0000000000..b4bb8649cd --- /dev/null +++ b/script/benchmark_notifications.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +# RAILS_ENV=test COHORT_SIZE=100 bundle exec rails runner script/benchmark_notifications.rb +# Synthetic transport benchmark. Requires a dedicated populated test DB and an +# empty, dedicated Redis DB; never points at SMTP or a browser push provider. +abort 'Run only in test with an isolated database' unless Rails.env.test? +require 'sidekiq/api' +require 'factory_bot_rails' +require 'faker' +FactoryBot.find_definitions unless FactoryBot.factories.registered?(:user) + +size = Integer(ENV.fetch('COHORT_SIZE'), 10) +abort 'COHORT_SIZE must be between 1 and 20000' unless size.between?(1, 20_000) +opt_out_percent = Integer(ENV.fetch('OPT_OUT_PERCENT', '20'), 10) +abort 'OPT_OUT_PERCENT must be between 0 and 100' unless opt_out_percent.between?(0, 100) +queues = %w[mailers notifications].map { |name| Sidekiq::Queue.new(name) } +abort 'Use an empty dedicated Redis DB; delivery queues are not empty' unless queues.all? { |queue| queue.size.zero? } # rubocop:disable Style/ZeroLengthPredicate +redis_database = URI.parse(ENV.fetch('DF_REDIS_SIDEKIQ_URL', '')).path.delete_prefix('/') +abort 'Use Redis DB 1 or higher for this isolated benchmark' unless redis_database.match?(/\A\d+\z/) && redis_database.to_i.positive? + +ActionMailer::Base.delivery_method = :test +ActionMailer::Base.perform_deliveries = true +push_deliveries = 0 +WebPush.singleton_class.define_method(:payload_send) do |**_args| + push_deliveries += 1 + true +end +key = WebPush.generate_key +ENV['DOUBTFIRE_VAPID_PUBLIC_KEY'] = key.public_key +ENV['DOUBTFIRE_VAPID_PRIVATE_KEY'] = key.private_key +ENV['DOUBTFIRE_NOTIFICATION_RECIPIENT_LIMIT'] = '100' +clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) } +rss = lambda do + File.read('/proc/self/status')[/^VmRSS:\s+(\d+)/, 1].to_i +rescue Errno::ENOENT + nil +end +users = [] +results = [] +nonce = SecureRandom.hex(8) + +begin + size.times do |index| + user = FactoryBot.create(:user, email: "notification-benchmark-#{nonce}-#{index}@example.invalid", + receive_feedback_notifications: index * 100 / size >= opt_out_percent) + users << user + PushSubscription.create!(user: user, endpoint: "https://fcm.googleapis.com/fcm/send/#{nonce}-#{index}", + p256dh: key.public_key, auth: Base64.urlsafe_encode64(SecureRandom.random_bytes(16), padding: false)) + end + user_ids = users.map(&:id) + eligible_recipients = users.count(&:receive_feedback_notifications) + peaks = {} + peak_lock = Mutex.new + observe = lambda do + peak_lock.synchronize do + peaks[:queue_depth] = [peaks[:queue_depth], queues.sum(&:size)].max + current_rss = rss.call + peaks[:rss_kib] = [peaks[:rss_kib].to_i, current_rss.to_i].max + end + end + + drain = lambda do + queues.each do |queue| + queue.each do |job| + klass = { 'NotificationEmailJob' => NotificationEmailJob, + 'PushNotificationDeliveryJob' => PushNotificationDeliveryJob }.fetch(job.klass) + klass.new.perform(*job.args) + job.delete + observe.call + end + end + end + + [[:inline_baseline, 1], [:queued, 1], [:concurrent_queued, 2]].each do |mode, events| + ActionMailer::Base.deliveries.clear + push_deliveries = 0 + peaks = { queue_depth: 0, rss_kib: rss.call } + event_prefix = "benchmark_#{nonce}_#{mode}" + started = clock.call + emit = lambda do |event_number| + users.each do |user| + user = User.find(user.id) if events > 1 + NotificationService.notify(user: user, type: 'feedback', event: "#{event_prefix}_#{event_number}", + message: 'Synthetic notification benchmark', link: '/notifications') + observe.call + drain.call if mode == :inline_baseline + end + end + if events > 1 + events.times.map do |event_number| + Thread.new { ActiveRecord::Base.connection_pool.with_connection { emit.call(event_number) } } + end.each(&:value) + else + emit.call(0) + end + enqueue_seconds = clock.call - started + drain_started = clock.call + drain.call + finished = clock.call + expected_deliveries = eligible_recipients * events + notifications = Notification.where(user_id: user_ids, event: events.times.map { |index| "#{event_prefix}_#{index}" }) + observed = { notifications: notifications.count, + email_states: notifications.group(:email_delivery_state).count, + email_attempts: notifications.sum(:email_delivery_attempts), + synthetic_emails: ActionMailer::Base.deliveries.length, + synthetic_pushes: push_deliveries, + remaining_queue_depth: queues.sum(&:size) } + expected_states = expected_deliveries.zero? ? {} : { 'delivered' => expected_deliveries } + unless observed == { notifications: expected_deliveries, email_states: expected_states, + email_attempts: expected_deliveries, synthetic_emails: expected_deliveries, + synthetic_pushes: expected_deliveries, remaining_queue_depth: 0 } + abort "Delivery count verification failed for #{mode}: #{observed.to_json}" + end + results << { mode: mode, cohort_size: size, simultaneous_events: events, + opt_out_percent: opt_out_percent, eligible_recipients: eligible_recipients, + suppressed_recipients: size - eligible_recipients, trigger_seconds: enqueue_seconds.round(4), + drain_seconds: (finished - drain_started).round(4), total_seconds: (finished - started).round(4), + peak_observed_queue_depth: peaks[:queue_depth], peak_observed_rss_kib: peaks[:rss_kib], + verified_delivery_counts: observed } + end + report = { transport: 'synthetic mail and push; no external delivery', + source_revision: ENV.fetch('BENCHMARK_SOURCE_REVISION', nil), + ruby_version: RUBY_VERSION, rails_version: Rails.version, + database_version: ActiveRecord::Base.connection.database_version.to_s, + recipient_limit_for_benchmark: 100, + configured_fanout_limit: NotificationDeliveryPolicy.positive_integer('DOUBTFIRE_NOTIFICATION_FANOUT_LIMIT', 500), + actual_largest_enrolment: ENV.fetch('LARGEST_UNIT_ENROLMENT', nil), + note: 'Direct service timing, including sampling; bypasses cohort admission. One drain worker; sampled peaks; fixed mode order without warm-up.', + results: results } +ensure + Notification.where(user_id: users.map(&:id)).delete_all + PushSubscription.where(user_id: users.map(&:id)).delete_all + users.each(&:destroy!) +end +report[:cleanup_verified] = User.where(id: user_ids).none? && Notification.where(user_id: user_ids).none? && + PushSubscription.where(user_id: user_ids).none? && queues.sum(&:size).zero? +abort 'Synthetic fixture cleanup failed' unless report[:cleanup_verified] + +json = JSON.pretty_generate(report) +File.write(ENV.fetch('BENCHMARK_RESULT_PATH'), "#{json}\n") if ENV['BENCHMARK_RESULT_PATH'].present? +puts json diff --git a/script/plan_test_shard_worker.rb b/script/plan_test_shard_worker.rb new file mode 100755 index 0000000000..067b9b3a25 --- /dev/null +++ b/script/plan_test_shard_worker.rb @@ -0,0 +1,67 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'fileutils' +require_relative 'test_shard' + +repository_root = File.expand_path('..', __dir__) +test_root = File.join(repository_root, 'test') +shard_count = TestShard.positive_integer('TEST_SHARD_COUNT') +worker_count = TestShard.positive_integer('TEST_SHARD_WORKER_COUNT') +worker_number = TestShard.positive_integer('TEST_SHARD_WORKER_NUMBER') +abort "TEST_SHARD_WORKER_NUMBER must be between 1 and #{worker_count}" if worker_number > worker_count + +shards = TestShard.build(test_root: test_root, shard_count: shard_count) +workers = TestShard.worker_assignments(shards: shards, worker_count: worker_count) +logical_shards = workers.fetch(worker_number - 1).fetch(:shard_numbers) +manifest_dir = ENV.fetch('TEST_SHARD_MANIFEST_DIR', File.join(repository_root, 'tmp/test-shard-manifests')) +plan_path = ENV.fetch('TEST_SHARD_WORKER_PLAN', File.join(repository_root, 'tmp/test-shard-worker-plan.tsv')) +github_output_path = ENV.fetch('TEST_SHARD_GITHUB_OUTPUT', nil) +cache_write_value = ENV.fetch('CI_IMAGE_CACHE_WRITE', 'false') +abort 'CI_IMAGE_CACHE_WRITE must be true or false' unless %w[true false].include?(cache_write_value) + +cache_write_enabled = cache_write_value == 'true' +cache_writers = TestShard.cache_writer_shards(shards) +if worker_number == 1 + selector_inventory_path = ENV.fetch('TEST_SHARD_SELECTOR_INVENTORY', nil) + TestShard.write_manifest(selector_inventory_path, TestShard.all_runnables(test_root: test_root)) +end + +FileUtils.mkdir_p(manifest_dir) +FileUtils.mkdir_p(File.dirname(plan_path)) +plan_rows = logical_shards.each_with_index.map do |shard_number, lane_index| + shard = shards.fetch(shard_number - 1) + runnables = shard.fetch(:runnables).sort + services = TestShard.required_services(runnables) + TestShard.write_manifest(File.join(manifest_dir, "shard-#{shard_number}.txt"), runnables) + [shard_number, lane_index, services.fetch(:texlive), services.fetch(:jplag)] +end + +jplag_shards = plan_rows.select { |row| row.fetch(3) }.map(&:first) +if jplag_shards.length > 1 + abort "Worker #{worker_number} assigned multiple JPlag shards: #{jplag_shards.join(', ')}" +end + +plan_contents = plan_rows.map { |row| row.join("\t") }.join("\n") +File.write(plan_path, "#{plan_contents}\n") +unless github_output_path.to_s.empty? + File.open(github_output_path, 'a') do |output| + %i[texlive jplag].each_with_index do |service, service_index| + service_column = service_index + 2 + output.puts "needs_#{service}=#{plan_rows.any? { |row| row.fetch(service_column) }}" + writes_cache = cache_write_enabled && logical_shards.include?(cache_writers.fetch(service)) + output.puts "writes_#{service}_cache=#{writes_cache}" + end + output.puts "logical_shards=#{logical_shards.join(',')}" + bake_targets = TestShard.image_build_targets( + shards: shards, + logical_shards: logical_shards, + api_cache_writer: cache_write_enabled && worker_number == worker_count, + cache_write_enabled: cache_write_enabled + ) + output.puts "bake_targets=#{bake_targets.join(',')}" + end +end + +puts "Test worker #{worker_number}/#{worker_count}: logical shards #{logical_shards.join(', ')}; " \ + "scheduling weight #{workers.fetch(worker_number - 1).fetch(:weight).round(1)}" diff --git a/script/prepare_test_database.sh b/script/prepare_test_database.sh new file mode 100755 index 0000000000..a12644fcc3 --- /dev/null +++ b/script/prepare_test_database.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "${SEEDED_DATABASE_CACHE_HIT:-}" == "true" ]]; then + gzip -t tmp/ci-seeded-database.sql.gz + tar -tzf tmp/ci-seeded-student-work.tar.gz >/dev/null + echo "Validated the populated test database cache; logical lanes import it directly." + exit 0 +fi + +echo "Populating a fresh test database." +bundle exec rake db:populate +bundle exec rails runner "abort 'db:populate created no units' unless Unit.exists?" diff --git a/script/run_test_shard_worker.sh b/script/run_test_shard_worker.sh new file mode 100755 index 0000000000..22f5130df2 --- /dev/null +++ b/script/run_test_shard_worker.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash + +set -euo pipefail + +workspace="${GITHUB_WORKSPACE:-$(pwd)}" +plan_path="${TEST_SHARD_WORKER_PLAN:-$workspace/tmp/test-shard-worker-plan.tsv}" +evidence_dir="$workspace/tmp" +lane_root="$workspace/tmp/test-shard-lanes" +student_work_root="$workspace/tmp/test-shard-student-work" +log_root="$workspace/tmp/test-shard-logs" +database_dump="$workspace/tmp/ci-seeded-database.sql.gz" +student_work_archive="$workspace/tmp/ci-seeded-student-work.tar.gz" +api_image="${TEST_SHARD_API_IMAGE:-doubtfire-api-ci:local}" +texlive_image="${TEST_SHARD_TEXLIVE_IMAGE:-doubtfire-texlive-development:local}" +jplag_image="${TEST_SHARD_JPLAG_IMAGE:-doubtfire-jplag-development:local}" + +required_variables=( + CI_SERVICE_NETWORK + DF_TEST_DB_ADAPTER + DF_TEST_DB_HOST + DF_TEST_DB_USERNAME + DF_TEST_DB_PASSWORD + TEST_SHARD_COUNT +) +for variable_name in "${required_variables[@]}"; do + if [[ -z "${!variable_name:-}" ]]; then + echo "Missing required environment variable: $variable_name" >&2 + exit 1 + fi +done + +if [[ ! -s "$plan_path" ]]; then + echo "Test shard worker plan is missing: $plan_path" >&2 + exit 1 +fi +gzip -t "$database_dump" +tar -tzf "$student_work_archive" >/dev/null + +database_container_id="$( + docker ps --filter "network=$CI_SERVICE_NETWORK" --filter ancestor=mariadb --format '{{.ID}}' | + head -n 1 +)" +redis_container_id="$( + docker ps --filter "network=$CI_SERVICE_NETWORK" --filter ancestor=redis:7.0 --format '{{.ID}}' | + head -n 1 +)" +if [[ -z "$database_container_id" || -z "$redis_container_id" ]]; then + echo 'Unable to locate the MariaDB and Redis service containers.' >&2 + exit 1 +fi + +mkdir -p "$lane_root" "$student_work_root" "$log_root" +declare -a logical_shards=() +declare -a redis_databases=() +declare -a database_names=() +declare -a lane_workspaces=() +declare -a student_workspaces=() +declare -a latex_names=() +declare -a texlive_requirements=() +declare -a jplag_requirements=() +declare -a api_container_names=() +declare -a helper_container_names=() +declare -a cleanup_container_names=() +jplag_lane_count=0 + +cleanup() { + for container_name in "${cleanup_container_names[@]:-}"; do + [[ -n "$container_name" ]] || continue + docker rm --force "$container_name" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT INT TERM + +cd "$workspace" +while IFS=$'\t' read -r logical_shard redis_database needs_texlive needs_jplag; do + if [[ ! "$logical_shard" =~ ^[0-9]+$ || ! "$redis_database" =~ ^[0-3]$ ]]; then + echo "Invalid logical shard plan row: $logical_shard $redis_database" >&2 + exit 1 + fi + if [[ "$needs_texlive" != 'true' && "$needs_texlive" != 'false' ]] || + [[ "$needs_jplag" != 'true' && "$needs_jplag" != 'false' ]]; then + echo "Invalid helper flags for logical shard $logical_shard" >&2 + exit 1 + fi + + database_name="doubtfire_test_shard_${logical_shard}" + lane_workspace="$lane_root/shard-$logical_shard" + student_workspace="$student_work_root/shard-$logical_shard" + latex_name="${LATEX_CONTAINER_NAME:-doubtfire-texlive}-shard-$logical_shard" + api_container_name="doubtfire-api-test-shard-$logical_shard" + + if [[ -e "$lane_workspace" || -e "$student_workspace" ]]; then + echo "Refusing to reuse an existing logical-shard workspace: $logical_shard" >&2 + exit 1 + fi + + logical_shards+=("$logical_shard") + redis_databases+=("$redis_database") + database_names+=("$database_name") + lane_workspaces+=("$lane_workspace") + student_workspaces+=("$student_workspace") + latex_names+=("$latex_name") + texlive_requirements+=("$needs_texlive") + jplag_requirements+=("$needs_jplag") + api_container_names+=("$api_container_name") + if [[ "$needs_texlive" == 'true' ]]; then + helper_container_names+=("$latex_name") + fi + if [[ "$needs_jplag" == 'true' ]]; then + helper_container_names+=(jplag) + jplag_lane_count=$((jplag_lane_count + 1)) + fi +done < "$plan_path" + +if [[ "${#logical_shards[@]}" -ne 4 ]]; then + echo "Expected four logical shards in $plan_path, found ${#logical_shards[@]}." >&2 + exit 1 +fi +if [[ "$(printf '%s\n' "${logical_shards[@]}" | sort -u | wc -l)" -ne 4 ]]; then + echo 'The worker plan contains duplicate logical shards.' >&2 + exit 1 +fi +if [[ "$(printf '%s\n' "${redis_databases[@]}" | sort -u | wc -l)" -ne 4 ]]; then + echo 'The worker plan must use each isolated Redis database exactly once.' >&2 + exit 1 +fi +if [[ "$jplag_lane_count" -gt 1 ]]; then + echo 'A physical worker cannot run more than one JPlag logical shard.' >&2 + exit 1 +fi +for container_name in "${api_container_names[@]}" "${helper_container_names[@]:-}"; do + [[ -n "$container_name" ]] || continue + if docker container inspect "$container_name" >/dev/null 2>&1; then + echo "Planned test container name is already in use: $container_name" >&2 + exit 1 + fi +done +cleanup_container_names=("${api_container_names[@]}" "${helper_container_names[@]:-}") + +for index in "${!logical_shards[@]}"; do + database_name="${database_names[$index]}" + redis_database="${redis_databases[$index]}" + docker exec "$database_container_id" mariadb --user=root --execute \ + "DROP DATABASE IF EXISTS \`$database_name\`; CREATE DATABASE \`$database_name\`; GRANT ALL ON \`$database_name\`.* TO '$DF_TEST_DB_USERNAME'@'%';" + docker exec "$redis_container_id" redis-cli -n "$redis_database" FLUSHDB >/dev/null +done + +setup_logical_shard() { + local index="$1" + local logical_shard="${logical_shards[$index]}" + local database_name="${database_names[$index]}" + local lane_workspace="${lane_workspaces[$index]}" + local student_workspace="${student_workspaces[$index]}" + local latex_name="${latex_names[$index]}" + + mkdir -p "$lane_workspace" "$student_workspace" + git ls-files -z | + tar --null --files-from=- --create | + tar --extract --directory="$lane_workspace" + mkdir -p "$lane_workspace/tmp/jplag" "$lane_workspace/log" + tar -xzf "$student_work_archive" -C "$student_workspace" + gzip -dc "$database_dump" | + docker exec --interactive "$database_container_id" mariadb --user=root "$database_name" + + if [[ "${texlive_requirements[$index]}" == 'true' ]]; then + docker run --detach \ + --name "$latex_name" \ + --network "$CI_SERVICE_NETWORK" \ + --volume "$student_workspace:/student-work" \ + --volume "$lane_workspace/public/assets/images:/doubtfire/public/assets/images" \ + --volume "$lane_workspace/test_files:/doubtfire/test_files" \ + --volume "$lane_workspace/tmp/rails-latex:/workdir/texlive-latex" \ + "$texlive_image" \ + sleep infinity >/dev/null + docker exec "$latex_name" lualatex -v >/dev/null + fi + + if [[ "${jplag_requirements[$index]}" == 'true' ]]; then + docker run --detach \ + --name jplag \ + --network "$CI_SERVICE_NETWORK" \ + --volume "$student_workspace:/student-work" \ + --volume "$lane_workspace/tmp/jplag:/tmp/jplag" \ + --volume "$lane_workspace/test_files/submissions/jplag:/test_files" \ + "$jplag_image" \ + sleep infinity >/dev/null + docker exec --env TERM=xterm jplag \ + java -jar /jplag/jplag-jar-with-dependencies.jar /test_files \ + -l java --similarity-threshold=0.30 -M RUN -r test.jplag >/dev/null + fi + + echo "Prepared logical shard $logical_shard." +} + +setup_started_at=$SECONDS +declare -a setup_processes=() +for index in "${!logical_shards[@]}"; do + setup_log_path="$log_root/shard-${logical_shards[$index]}-setup.log" + setup_logical_shard "$index" >"$setup_log_path" 2>&1 & + setup_processes+=("$!") +done + +setup_failed=0 +for index in "${!logical_shards[@]}"; do + logical_shard="${logical_shards[$index]}" + if wait "${setup_processes[$index]}"; then + outcome='passed' + else + outcome='failed' + setup_failed=1 + fi + echo "::group::Set up logical shard $logical_shard/$TEST_SHARD_COUNT ($outcome)" + cat "$log_root/shard-$logical_shard-setup.log" + echo '::endgroup::' +done +echo "Prepared four logical-shard lanes in $((SECONDS - setup_started_at))s." +if [[ "$setup_failed" -ne 0 ]]; then + exit 1 +fi + +run_logical_shard() { + local index="$1" + local logical_shard="${logical_shards[$index]}" + local redis_database="${redis_databases[$index]}" + local database_name="${database_names[$index]}" + local lane_workspace="${lane_workspaces[$index]}" + local student_workspace="${student_workspaces[$index]}" + local latex_name="${latex_names[$index]}" + local api_container_name="${api_container_names[$index]}" + + docker run --rm \ + --name "$api_container_name" \ + --network "$CI_SERVICE_NETWORK" \ + --volume "$lane_workspace:/doubtfire" \ + --volume "$student_workspace:/student-work" \ + --volume "$evidence_dir:/evidence" \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$lane_workspace/tmp/jplag:/tmp/jplag" \ + --env TERM=xterm \ + --env RAILS_ENV \ + --env DF_INSTITUTION_HOST \ + --env DF_INSTITUTION_PRODUCT_NAME \ + --env DF_SECRET_KEY_BASE \ + --env DF_SECRET_KEY_ATTR \ + --env DF_SECRET_KEY_DEVISE \ + --env DF_TEST_DB_ADAPTER \ + --env DF_TEST_DB_HOST \ + --env "DF_TEST_DB_DATABASE=$database_name" \ + --env DF_TEST_DB_USERNAME \ + --env DF_TEST_DB_PASSWORD \ + --env OVERSEER_ENABLED \ + --env DF_ENCRYPTION_PRIMARY_KEY \ + --env DF_ENCRYPTION_DETERMINISTIC_KEY \ + --env DF_ENCRYPTION_KEY_DERIVATION_SALT \ + --env "DF_REDIS_SIDEKIQ_URL=redis://redis:6379/$redis_database" \ + --env "DF_STUDENT_WORK_DIR=/student-work" \ + --env "LATEX_CONTAINER_NAME=$latex_name" \ + --env LATEX_BUILD_PATH \ + --env LTI_SHARED_API_SECRET \ + --env LTI_ENABLED \ + --env TEST_SHARD_COUNT \ + --env "TEST_SHARD_NUMBER=$logical_shard" \ + --env "TEST_SHARD_MANIFEST=/evidence/test-shard-manifests/shard-$logical_shard.txt" \ + --env "TEST_SHARD_RUN_COUNT=/evidence/test-shard-run-counts/shard-$logical_shard.txt" \ + --env "TEST_SHARD_EXECUTED_RUNNABLES=/evidence/test-shard-executed-runnables/shard-$logical_shard.txt" \ + --env "TEST_RUNNABLE_INVENTORY=/evidence/test-runnable-inventory.txt" \ + "$api_image" \ + bundle exec ruby script/test_shard.rb +} + +declare -a shard_processes=() +tests_started_at=$SECONDS +for index in "${!logical_shards[@]}"; do + log_path="$log_root/shard-${logical_shards[$index]}.log" + run_logical_shard "$index" >"$log_path" 2>&1 & + shard_processes+=("$!") +done + +worker_failed=0 +for index in "${!logical_shards[@]}"; do + logical_shard="${logical_shards[$index]}" + if wait "${shard_processes[$index]}"; then + outcome='passed' + else + outcome='failed' + worker_failed=1 + fi + echo "::group::Logical shard $logical_shard/$TEST_SHARD_COUNT ($outcome)" + cat "$log_root/shard-$logical_shard.log" + echo '::endgroup::' +done +echo "Ran four logical test shards in $((SECONDS - tests_started_at))s." + +exit "$worker_failed" diff --git a/script/test_inventory.rb b/script/test_inventory.rb new file mode 100755 index 0000000000..75dd1bae5a --- /dev/null +++ b/script/test_inventory.rb @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Build the canonical Minitest runnable inventory without executing the suite. +# CI compares this count with the sum reported by every shard, preventing a +# sharding change from appearing faster by silently filtering tests out. + +require 'fileutils' + +$LOAD_PATH.unshift(File.expand_path('../test', __dir__)) +require_relative '../test/test_helper' +require_relative 'test_shard' + +def fail_inventory(message) + warn message + $stdout.flush + $stderr.flush + exit! 1 +end + +inventory_path = ARGV.fetch(0) { fail_inventory 'Expected an inventory output path' } +repository_root = File.expand_path('..', __dir__) +test_root = File.join(repository_root, 'test') +Minitest.seed = 1 +preloaded_runnables = Minitest::Runnable.runnables.dup + +begin + TestShard::SPLIT_TEST_FILES.each_key do |relative_path| + path = File.join(repository_root, relative_path) + before = Minitest::Runnable.runnables.dup + require path + added_classes = Minitest::Runnable.runnables - before + actual_selectors = added_classes.flat_map do |test_class| + test_class.runnable_methods.map do |method_name| + source_path, line_number = test_class.instance_method(method_name).source_location + relative_source = source_path&.delete_prefix("#{repository_root}/") + "#{relative_source}:#{line_number}" + end + end + expected_selectors = TestShard.method_runnables(path, relative_path).map do |method| + method.fetch(:runnable) + end + next if actual_selectors.sort == expected_selectors.sort && + actual_selectors.uniq.length == actual_selectors.length + + fail_inventory <<~MESSAGE + Split-test selector mismatch for #{relative_path}. + Expected from source: #{expected_selectors.sort.inspect} + Actual Minitest runnables: #{actual_selectors.sort.inspect} + MESSAGE + end + + Dir.glob(File.join(test_root, '**', '*_test.rb')).each { |path| require path } + suite_classes = Minitest::Runnable.runnables - preloaded_runnables + suite_classes.select! { |test_class| test_class.is_a?(Class) && test_class < Minitest::Test } + entries = suite_classes.flat_map do |test_class| + class_name = test_class.name + fail_inventory 'A concrete test class has no stable name' if class_name.to_s.empty? + + test_class.runnable_methods.map { |method_name| "#{class_name}##{method_name}" } + end + fail_inventory 'The test runnable inventory is empty' if entries.empty? + fail_inventory 'The test runnable inventory contains duplicate identifiers' if entries.uniq.length != entries.length + + FileUtils.mkdir_p(File.dirname(inventory_path)) + File.write(inventory_path, "#{entries.sort.join("\n")}\n") + puts "Inventoried #{entries.length} Minitest runnables." + $stdout.flush + exit! 0 +rescue StandardError, ScriptError => e + warn "Unable to build test runnable inventory: #{e.full_message}" + $stdout.flush + $stderr.flush + exit! 1 +end diff --git a/script/test_shard.rb b/script/test_shard.rb new file mode 100755 index 0000000000..eebeb8f372 --- /dev/null +++ b/script/test_shard.rb @@ -0,0 +1,548 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'fileutils' +require 'digest' +require 'open3' + +# Split the Rails test suite into deterministic, approximately even shards. +# +# Most files remain the atomic unit. The few files that have repeatedly taken +# several minutes in hosted CI are split into balanced groups of test methods, +# using Rails' supported file:line selector. This keeps every worker isolated +# while removing the longest single-file bottlenecks. +# +# Preview a shard without running Rails by passing --dry-run. Pass +# --list-runnables to print the canonical coverage manifest used by CI. +# Set TEST_SHARD_MANIFEST to write the selected runnable list and +# TEST_SHARD_GITHUB_OUTPUT to expose helper-service requirements to Actions. +module TestShard + module_function + + DEFAULT_LINES_PER_SECOND = 20.0 + + # These are the single-file bottlenecks observed in hosted runs. Splitting + # only known bottlenecks keeps the plan maintainable while removing files + # that would otherwise set the lower bound for the slowest shard. + SPLIT_TEST_FILES = { + 'test/api/feedback/feedback_chip_api_consolidated_test.rb' => 2, + 'test/api/groups_api_test.rb' => 2, + 'test/api/peer_progress_api_test.rb' => 2, + 'test/api/tasks_api_test.rb' => 3, + 'test/api/tutorials_test.rb' => 3, + 'test/api/units/task_definitions_api_test.rb' => 3, + 'test/api/upload_security_test.rb' => 3, + 'test/models/task_test.rb' => 3, + 'test/models/unit_model_test.rb' => 3 + }.freeze + + # Source size is the fallback weight. These conservative hosted upper bounds + # correct the largest known outliers where line count mispredicts runtime. + FILE_RUNTIME_WEIGHTS = { + 'test/api/csv_test.rb' => 55.0, + 'test/api/feedback/feedback_chip_api_consolidated_test.rb' => 60.0, + 'test/api/groups_api_test.rb' => 70.0, + 'test/api/peer_progress_api_test.rb' => 90.0, + 'test/api/projects_api_test.rb' => 35.0, + 'test/api/tasks_api_test.rb' => 152.0, + 'test/api/tutorials_test.rb' => 100.0, + 'test/api/units/task_definitions_api_test.rb' => 151.0, + 'test/api/upload_security_test.rb' => 173.0, + 'test/config/deakin_config_test.rb' => 50.0, + 'test/models/notification_group_test.rb' => 30.0, + 'test/models/task_test.rb' => 210.0, + 'test/models/unit_model_test.rb' => 180.0, + 'test/sidekiq/send_due_soon_reminders_job_test.rb' => 75.0 + }.freeze + + SERVICE_TEST_FILES = { + texlive: %w[ + test/api/projects_api_test.rb + test/api/tasks_api_test.rb + test/api/units/task_definitions_api_test.rb + test/models/project_model_test.rb + test/models/task_similarity_test.rb + test/models/task_test.rb + test/models/tii_model_test.rb + test/models/unit_model_test.rb + ].freeze, + jplag: %w[ + test/models/task_similarity_test.rb + ].freeze + }.freeze + + # Hosted setup time paid once by each shard that needs a helper. Including + # it in the greedy score keeps helper-backed tests together when doing so is + # faster than starting another copy of the service. + SERVICE_SETUP_WEIGHTS = { + texlive: 25.0, + jplag: 27.0 + }.freeze + + # Hosted Minitest timings for the exact sorted runnable inventory. Using + # selector-level weights fixes the large skew that source size cannot + # predict. Any inventory mismatch falls back to the conservative estimates. + HOSTED_RUNNABLE_RUNTIME_PROFILE = { + selector_count: 402, + fingerprint: '741ba43118789cb114a817d7f17713558d6a9197b9b27ff16e94f7de2f43014b', + weights: [ + 2.74, 5.42, 2.69, 0.12, 1.06, 30.10, 21.80, 16.62, + 2.70, 35.88, 14.04, 10.22, 17.52, 2.76, 4.23, 1.43, + 4.34, 0.04, 0.04, 0.06, 0.75, 0.04, 0.04, 0.06, + 0.05, 0.04, 0.04, 0.06, 1.47, 35.98, 42.32, 4.47, + 3.87, 4.22, 4.68, 4.26, 4.02, 4.37, 3.90, 3.86, + 4.26, 22.90, 51.70, 4.64, 8.14, 5.90, 0.58, 0.62, + 0.61, 0.58, 0.63, 0.61, 0.58, 0.62, 0.60, 0.62, + 0.63, 0.62, 0.64, 0.60, 0.62, 0.60, 0.61, 0.87, + 0.82, 0.60, 0.60, 0.90, 0.61, 0.60, 0.62, 0.57, + 0.60, 0.64, 0.81, 0.62, 0.62, 0.60, 0.58, 0.60, + 0.60, 0.65, 0.60, 0.62, 0.64, 0.63, 0.64, 1.48, + 1.50, 0.59, 0.64, 0.56, 31.05, 6.25, 11.81, 0.08, + 0.05, 33.12, 20.60, 10.06, 8.46, 2.26, 2.26, 2.16, + 2.28, 1.46, 11.26, 2.72, 1.58, 6.30, 4.34, 4.44, + 4.72, 3.64, 5.78, 4.65, 24.18, 4.98, 20.36, 2.18, + 2.52, 3.20, 2.14, 2.22, 2.04, 2.20, 2.26, 2.44, + 2.46, 0.78, 47.32, 9.34, 3.22, 7.22, 6.38, 8.64, + 8.86, 8.48, 4.56, 4.48, 4.44, 4.57, 0.22, 4.26, + 4.23, 4.20, 1.06, 4.26, 4.26, 4.32, 4.24, 4.25, + 4.44, 4.31, 4.86, 4.33, 4.33, 4.40, 4.12, 4.50, + 4.54, 4.68, 4.29, 4.62, 8.86, 8.99, 8.63, 8.92, + 9.14, 8.57, 8.64, 7.04, 11.90, 3.08, 4.40, 4.14, + 4.50, 4.12, 4.22, 4.40, 0.06, 0.34, 0.04, 0.22, + 4.45, 15.84, 1.12, 1.30, 1.17, 1.20, 1.26, 1.28, + 1.98, 2.06, 3.28, 22.22, 1.88, 2.14, 2.26, 2.37, + 2.12, 2.23, 2.02, 2.10, 0.01, 0.01, 2.23, 2.04, + 0.01, 0.01, 0.02, 0.01, 0.01, 0.01, 2.25, 1.96, + 2.00, 0.02, 0.01, 0.01, 0.01, 0.02, 0.01, 0.01, + 2.18, 2.10, 2.09, 2.00, 1.96, 2.10, 1.90, 2.18, + 0.87, 8.53, 0.01, 47.11, 0.01, 0.01, 0.62, 19.13, + 0.30, 0.04, 5.54, 18.47, 0.10, 0.12, 0.54, 0.04, + 0.08, 0.94, 0.98, 4.66, 0.02, 9.63, 0.15, 2.68, + 1.18, 4.91, 60.17, 0.01, 5.11, 6.30, 35.49, 11.04, + 8.54, 8.98, 11.34, 7.56, 1.72, 6.88, 0.01, 18.04, + 0.06, 0.01, 12.70, 57.64, 0.04, 5.86, 0.10, 0.01, + 12.46, 10.00, 0.01, 20.75, 0.01, 8.72, 33.92, 31.71, + 1.10, 0.92, 33.32, 2.12, 1.18, 2.44, 2.56, 3.36, + 1.02, 2.38, 2.00, 2.14, 1.97, 2.41, 2.10, 1.00, + 1.92, 1.85, 2.20, 2.04, 2.10, 2.10, 0.91, 12.57, + 22.18, 1.08, 2.12, 11.26, 13.48, 15.02, 14.31, 1.00, + 48.98, 25.62, 0.95, 12.70, 13.78, 0.96, 13.68, 1.08, + 1.11, 1.66, 9.14, 17.96, 4.98, 0.01, 0.01, 17.82, + 4.44, 13.40, 0.40, 1.55, 0.35, 0.35, 2.84, 18.26, + 1.44, 2.26, 2.26, 1.66, 1.72, 1.24, 1.20, 2.36, + 0.44, 0.54, 0.31, 2.78, 0.60, 2.04, 2.26, 1.16, + 1.36, 1.16, 1.38, 1.56, 2.16, 1.54, 15.31, 9.02, + 3.20, 6.63, 3.76, 3.48, 0.65, 1.46, 1.06, 2.00, + 1.38, 1.40, 47.62, 2.18, 23.12, 0.03, 4.14, 17.76, + 0.07, 0.08, 7.68, 0.01, 0.10, 0.08, 8.98, 0.94, + 5.87, 1.44, 1.28, 0.06, 69.18, 8.36, 37.88, 11.08, + 0.08, 0.24 + ] + }.freeze + + # Optional second-level profile for packing already-built logical shards + # onto physical workers. The selector profile normally makes this redundant. + HOSTED_SHARD_RUNTIME_PROFILE = {}.freeze + + TEST_METHOD_PATTERN = /^\s*(?:def\s+test_[A-Za-z0-9_!?=]*|test\s*(?:\(\s*)?['":])/ + TEST_DECLARATION_CANDIDATE_PATTERN = /^\s*(?:def\s+test_|test\b|define_method\b.*test_)/ + + def repository_relative(path, test_root) + path.delete_prefix("#{File.dirname(test_root)}/") + end + + def method_runnables(path, relative_path) + lines = File.readlines(path) + starts = lines.each_index.with_object([]) do |index, result| + line = lines[index] + if line.match?(TEST_DECLARATION_CANDIDATE_PATTERN) && !line.match?(TEST_METHOD_PATTERN) + abort "Unsupported test declaration in split test file #{relative_path}:#{index + 1}" + end + result << (index + 1) if line.match?(TEST_METHOD_PATTERN) + end + abort "No test methods found in split test file #{relative_path}" if starts.empty? + + weighted_methods = starts.each_with_index.map do |line_number, index| + next_line = starts[index + 1] || (lines.length + 1) + { + runnable: "#{relative_path}:#{line_number}", + line_count: next_line - line_number + } + end + total_lines = weighted_methods.sum { |method| method.fetch(:line_count) } + runtime_weight = file_weight(relative_path, lines.length) + + weighted_methods.each do |method| + method[:weight] = runtime_weight * method.fetch(:line_count) / total_lines + end + end + + def file_weight(relative_path, line_count) + FILE_RUNTIME_WEIGHTS.fetch(relative_path, line_count / DEFAULT_LINES_PER_SECOND) + end + + def split_units(path, relative_path, part_count, runtime_weights: {}) + methods = method_runnables(path, relative_path).map do |method| + method.merge(weight: runtime_weights.fetch(method.fetch(:runnable), method.fetch(:weight))) + end + abort "Cannot split #{relative_path} into #{part_count} non-empty parts" if part_count > methods.length + + parts = Array.new(part_count) { { weight: 0.0, line_count: 0, runnables: [] } } + methods.sort_by { |method| [-method.fetch(:weight), method.fetch(:runnable)] }.each do |method| + part_index = parts.each_index.min_by { |index| [parts[index][:weight], index] } + parts[part_index][:runnables] << method.fetch(:runnable) + parts[part_index][:weight] += method.fetch(:weight) + parts[part_index][:line_count] += method.fetch(:line_count) + end + parts + end + + def canonical_runnables(test_root:) + test_files = Dir.glob(File.join(test_root, '**', '*_test.rb')) + abort "No test files found under #{test_root}" if test_files.empty? + + test_files.sort.flat_map do |path| + relative_path = repository_relative(path, test_root) + part_count = SPLIT_TEST_FILES[relative_path] + next method_runnables(path, relative_path).map { |method| method.fetch(:runnable) } if part_count + + relative_path + end.sort + end + + def runnable_profile_fingerprint(test_root:, runnables:) + digest = Digest::SHA256.new + digest << runnables.join("\0") + Dir.glob(File.join(test_root, '**', '*'), File::FNM_DOTMATCH).select { |path| File.file?(path) }.sort.each do |path| + relative_path = path.delete_prefix("#{test_root}/") + digest << "\0#{relative_path}\0" << File.binread(path) + end + digest.hexdigest + end + + def hosted_runtime_weights(test_root:, runtime_profile:) + return {} if runtime_profile.empty? + + runnables = canonical_runnables(test_root: test_root) + return {} unless runtime_profile.fetch(:selector_count, nil) == runnables.length + fingerprint = runnable_profile_fingerprint(test_root: test_root, runnables: runnables) + return {} unless runtime_profile.fetch(:fingerprint, nil) == fingerprint + + weights = runtime_profile.fetch(:weights, nil) + valid_weights = weights.is_a?(Array) && weights.length == runnables.length && weights.all? do |weight| + weight.is_a?(Numeric) && weight.positive? && (!weight.respond_to?(:finite?) || weight.finite?) + end + abort 'The hosted runnable runtime profile contains invalid weights' unless valid_weights + + runnables.zip(weights).to_h + end + + def runnable_units(test_root:, runtime_profile: HOSTED_RUNNABLE_RUNTIME_PROFILE) + runtime_weights = hosted_runtime_weights(test_root: test_root, runtime_profile: runtime_profile) + + Dir.glob(File.join(test_root, '**', '*_test.rb')).flat_map do |path| + relative_path = repository_relative(path, test_root) + part_count = SPLIT_TEST_FILES[relative_path] + if part_count + next split_units(path, relative_path, part_count, runtime_weights: runtime_weights) + end + + line_count = File.foreach(path).count + [{ + weight: runtime_weights.fetch(relative_path, file_weight(relative_path, line_count)), + line_count: line_count, + runnables: [relative_path] + }] + end + end + + def all_runnables(test_root:) + canonical_runnables(test_root: test_root) + end + + def build(test_root:, shard_count:, runtime_profile: HOSTED_RUNNABLE_RUNTIME_PROFILE) + units = runnable_units(test_root: test_root, runtime_profile: runtime_profile) + abort "TEST_SHARD_COUNT cannot exceed the #{units.length} discovered runnable groups" if shard_count > units.length + + shards = Array.new(shard_count) do + { weight: 0.0, line_count: 0, runnables: [], services: {} } + end + units.sort_by { |unit| [-unit.fetch(:weight), unit.fetch(:runnables).first] }.each do |unit| + unit_services = required_services(unit.fetch(:runnables)).select { |_service, required| required }.keys + shard_index = shards.each_index.min_by do |index| + new_service_weight = unit_services.sum do |service| + shards[index][:services][service] ? 0.0 : SERVICE_SETUP_WEIGHTS.fetch(service) + end + [shards[index][:weight] + new_service_weight, index] + end + shard = shards.fetch(shard_index) + unit_services.each do |service| + next if shard[:services][service] + + shard[:services][service] = true + shard[:weight] += SERVICE_SETUP_WEIGHTS.fetch(service) + end + shard[:runnables].concat(unit.fetch(:runnables)) + shard[:weight] += unit.fetch(:weight) + shard[:line_count] += unit.fetch(:line_count) + end + + assigned_runnables = shards.flat_map { |shard| shard.fetch(:runnables) } + expected_runnables = all_runnables(test_root: test_root) + unless assigned_runnables.length == expected_runnables.length && + assigned_runnables.uniq.length == expected_runnables.length && + assigned_runnables.sort == expected_runnables + abort 'Internal error: test sharding did not assign every runnable exactly once' + end + + shards + end + + def positive_integer(name) + value = Integer(ENV.fetch(name, ''), exception: false) + abort "#{name} must be a positive integer" unless value&.positive? + + value + end + + def write_manifest(path, selected_runnables) + return if path.to_s.empty? + + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#{selected_runnables.join("\n")}\n") + end + + def source_file(runnable) + runnable.sub(/:\d+\z/, '') + end + + def required_services(selected_runnables) + selected_files = selected_runnables.map { |runnable| source_file(runnable) }.uniq + SERVICE_TEST_FILES.transform_values do |service_files| + service_files.any? { |service_file| selected_files.include?(service_file) } + end + end + + def cache_writer_shards(shards) + SERVICE_TEST_FILES.keys.each_with_object({}) do |service, writers| + index = shards.index do |shard| + required_services(shard.fetch(:runnables)).fetch(service) + end + writers[service] = index && (index + 1) + end + end + + def image_build_targets(shards:, logical_shards:, api_cache_writer:, cache_write_enabled: true) + targets = [api_cache_writer ? 'api-cache-writer' : 'api'] + selected_runnables = logical_shards.flat_map do |shard_number| + shards.fetch(shard_number - 1).fetch(:runnables) + end + required = required_services(selected_runnables) + cache_writers = cache_writer_shards(shards) + + SERVICE_TEST_FILES.each_key do |service| + next unless required.fetch(service) + + target = service.to_s + if cache_write_enabled && logical_shards.include?(cache_writers.fetch(service)) + target += '-cache-writer' + end + targets << target + end + targets + end + + def shard_plan_fingerprint(shards) + contents = shards.each_with_index.map do |shard, index| + "#{index + 1}\0#{shard.fetch(:runnables).sort.join("\0")}" + end + Digest::SHA256.hexdigest(contents.join("\n")) + end + + def scheduling_weights(shards:, worker_count:, runtime_profile:) + fallback = shards.map { |shard| shard.fetch(:weight) } + return fallback unless runtime_profile.fetch(:shard_count, nil) == shards.length + return fallback unless runtime_profile.fetch(:worker_count, nil) == worker_count + return fallback unless runtime_profile.fetch(:fingerprint, nil) == shard_plan_fingerprint(shards) + + weights = runtime_profile.fetch(:weights, nil) + unless weights.is_a?(Array) && weights.length == shards.length && weights.all?(&:positive?) + abort 'The hosted shard runtime profile contains invalid weights' + end + weights + end + + # Pack logical shards onto the smaller number of hosted runners available to + # the repository. Each worker runs its assigned logical shards concurrently, + # so balancing their combined measured weight avoids four waves of queued + # GitHub jobs when the account has five runner slots. + def worker_assignments(shards:, worker_count:, runtime_profile: HOSTED_SHARD_RUNTIME_PROFILE) + abort 'TEST_SHARD_WORKER_COUNT must be a positive integer' unless worker_count.positive? + if worker_count > shards.length + abort "TEST_SHARD_WORKER_COUNT cannot exceed the #{shards.length} logical shards" + end + unless (shards.length % worker_count).zero? + abort 'Logical shard count must be divisible by TEST_SHARD_WORKER_COUNT' + end + + worker_weights = scheduling_weights( + shards: shards, + worker_count: worker_count, + runtime_profile: runtime_profile + ) + shards_per_worker = shards.length / worker_count + workers = Array.new(worker_count) { { weight: 0.0, shard_numbers: [] } } + weighted_shard_indices = shards.each_index.sort_by do |index| + [-worker_weights.fetch(index), index] + end + weighted_shard_indices.each do |index| + eligible_workers = workers.each_index.select do |worker_index| + workers.fetch(worker_index).fetch(:shard_numbers).length < shards_per_worker + end + worker_index = eligible_workers.min_by do |candidate| + [workers.fetch(candidate).fetch(:weight), candidate] + end + worker = workers.fetch(worker_index) + worker.fetch(:shard_numbers) << (index + 1) + worker[:weight] += worker_weights.fetch(index) + end + workers.each { |worker| worker.fetch(:shard_numbers).sort! } + + assigned = workers.flat_map { |worker| worker.fetch(:shard_numbers) } + expected = (1..shards.length).to_a + unless assigned.sort == expected && assigned.uniq.length == expected.length + abort 'Internal error: worker packing did not assign every logical shard exactly once' + end + + workers + end + + def write_github_output(path, selected_runnables, cache_writer_services: {}) + return if path.to_s.empty? + + File.open(path, 'a') do |output| + required_services(selected_runnables).each do |service, required| + output.puts "needs_#{service}=#{required}" + output.puts "writes_#{service}_cache=#{cache_writer_services.fetch(service, false)}" + end + end + end + + # Rails resolves each filter when its suite runs. Some integration tests + # change the process working directory, so relative paths for later suites + # can silently resolve outside the repository and select no tests. Execute + # absolute paths while keeping repository-relative paths in CI manifests. + def execution_runnables(selected_runnables, repository_root:) + selected_runnables.map do |runnable| + relative_source = source_file(runnable) + line_suffix = runnable.delete_prefix(relative_source) + "#{File.expand_path(relative_source, repository_root)}#{line_suffix}" + end + end + + def run_test_command(runnables) + run_count = nil + executed_runnables = [] + status = nil + Open3.popen2e('bundle', 'exec', 'rails', 'test', *runnables, '--verbose') do |_stdin, output, wait_thread| + output.each do |line| + print line + summary_match = line.match(/([\d,]+) runs, [\d,]+ assertions/) + run_count = Integer(summary_match[1].delete(',')) if summary_match + runnable_match = line.match(/\A([A-Za-z0-9_:]+)#(test_.+?) =/) + executed_runnables << "#{runnable_match[1]}##{runnable_match[2]}" if runnable_match + end + status = wait_thread.value + end + [status.success?, run_count, executed_runnables] + end + + def write_run_count(path, run_count) + return if path.to_s.empty? + + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#{run_count}\n") + end + + def write_executed_runnables(path, executed_runnables) + return if path.to_s.empty? + + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#{executed_runnables.sort.join("\n")}\n") + end + + def run_tests(selected_runnables, repository_root:, run_count_path:, executed_runnables_path: nil) + runnables = execution_runnables(selected_runnables, repository_root: repository_root) + puts "Rails test invocation: #{runnables.join(' ')}" + $stdout.flush + successful, run_count, executed_runnables = run_test_command(runnables) + if run_count.nil? + warn 'Rails test invocation produced no Minitest run count' + successful = false + run_count = 0 + elsif executed_runnables.length != run_count + warn "Rails test invocation reported #{run_count} tests, " \ + "but #{executed_runnables.length} runnable identifiers were captured" + successful = false + end + write_run_count(run_count_path, run_count) + write_executed_runnables(executed_runnables_path, executed_runnables) + exit 1 unless successful + end + + def run(argv) + valid_arguments = ['--dry-run', '--list-runnables'] + unknown_arguments = argv - valid_arguments + abort "Unknown argument(s): #{unknown_arguments.join(' ')}" unless unknown_arguments.empty? + if argv.include?('--list-runnables') && argv.length > 1 + abort '--list-runnables cannot be combined with another argument' + end + + repository_root = File.expand_path('..', __dir__) + test_root = File.join(repository_root, 'test') + if argv.include?('--list-runnables') + puts all_runnables(test_root: test_root) + return + end + + shard_count = positive_integer('TEST_SHARD_COUNT') + shard_number = positive_integer('TEST_SHARD_NUMBER') + abort "TEST_SHARD_NUMBER must be between 1 and #{shard_count}" if shard_number > shard_count + + shards = build(test_root: test_root, shard_count: shard_count) + selected_shard = shards.fetch(shard_number - 1) + selected_runnables = selected_shard.fetch(:runnables).sort + + puts "Test shard #{shard_number}/#{shard_count}: " \ + "#{selected_runnables.length} of #{shards.sum { |shard| shard[:runnables].length }} runnables, " \ + "estimated weight #{selected_shard[:weight].round(1)}" + selected_runnables.each { |runnable| puts " #{runnable}" } + write_manifest(ENV.fetch('TEST_SHARD_MANIFEST', nil), selected_runnables) + cache_writers = cache_writer_shards(shards) + cache_writer_services = cache_writers.transform_values { |writer| writer == shard_number } + write_github_output( + ENV.fetch('TEST_SHARD_GITHUB_OUTPUT', nil), + selected_runnables, + cache_writer_services: cache_writer_services + ) + + return if argv.include?('--dry-run') + + $stdout.flush + Dir.chdir(repository_root) do + inventory_path = ENV.fetch('TEST_RUNNABLE_INVENTORY', nil) + if shard_number == 1 && !inventory_path.to_s.empty? + inventory_successful = system('bundle', 'exec', 'ruby', 'script/test_inventory.rb', inventory_path) + exit 1 unless inventory_successful + end + run_tests( + selected_runnables, + repository_root: repository_root, + run_count_path: ENV.fetch('TEST_SHARD_RUN_COUNT', nil), + executed_runnables_path: ENV.fetch('TEST_SHARD_EXECUTED_RUNNABLES', nil) + ) + end + end +end + +TestShard.run(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/test/api/api_root_test.rb b/test/api/api_root_test.rb new file mode 100644 index 0000000000..b8ba795565 --- /dev/null +++ b/test/api/api_root_test.rb @@ -0,0 +1,88 @@ +require 'test_helper' + +# Guards the wiring in app/api/api_root.rb: every Grape API that is mounted must +# also be passed through AuthenticationHelpers.add_auth_to, unless it is on the +# short allowlist of endpoints that are deliberately public. Without this, a new +# endpoint mounted without add_auth_to loses its Swagger authentication metadata. +# Runtime authentication is enforced separately by each API's before block. The test reads the source rather than the running app so it does not +# depend on boot order or config flags. +class ApiRootTest < ActiveSupport::TestCase + API_ROOT_PATH = Rails.root.join("app/api/api_root.rb").freeze + + # Endpoints that are public by design. Keep one comment per entry so a change + # here is a deliberate, reviewable decision. + PUBLIC_ALLOWLIST = [ + 'ActivityTypesPublicApi', # read-only list of activity types + # Completes an emailed verification link. The link is often opened on a + # device with no OnTrack session, and the web client posts the token with + # or without one, so a session cannot be required. The signed, expiring, + # single-use token in the body is the authorisation. + 'AdditionalNotificationEmailVerificationApi', + 'AuthenticationApi', # sign in, cannot require a session + 'CampusesPublicApi', # read-only list of campuses + 'D2lIntegrationApi::OauthPublicApi', # OAuth callback from D2L + 'SettingsPublicApi', # branding for the login page + 'TaskStatusesApi', # read-only list of the fixed task statuses + 'TeachingPeriodsPublicApi', # read-only list of teaching periods + 'Tii::TurnItInHooksApi', # inbound webhook from Turnitin, own auth + 'WebcalPublicApi' # calendar feed authorised by a per-user secret + ].freeze + + # `mount SomeApi`, `mount(SomeApi)` and `mount SomeApi if ` all count. + MOUNT_LINE = /^\s*mount\b/ + MOUNT_CALL = /^\s*mount[\s(]+([A-Za-z0-9_:]+)/ + # Only an executable line counts. Anchored to the start so the class name in a + # comment or a string cannot satisfy the guard. + ADD_AUTH_CALL = /^\s*AuthenticationHelpers\.add_auth_to\s+([A-Za-z0-9_:]+)/ + + def source + @source ||= File.read(API_ROOT_PATH) + end + + def mount_lines + source.lines.grep(MOUNT_LINE) + end + + def mounted_apis + mount_lines.filter_map { |line| line[MOUNT_CALL, 1] } + end + + def authenticated_apis + source.scan(ADD_AUTH_CALL).flatten.to_set + end + + def test_every_mounted_api_is_authenticated_or_allowlisted + allowed = PUBLIC_ALLOWLIST.to_set + authenticated = authenticated_apis + + unguarded = mounted_apis.reject do |api| + authenticated.include?(api) || allowed.include?(api) + end + + assert_empty unguarded, + "These APIs are mounted in api_root.rb but neither pass through " \ + "AuthenticationHelpers.add_auth_to nor sit on PUBLIC_ALLOWLIST: " \ + "#{unguarded.join(', ')}. Add the endpoint to add_auth_to, or, if it is " \ + "genuinely public, add it to PUBLIC_ALLOWLIST here with a reason." + end + + def test_allowlisted_apis_are_actually_mounted + mounted = mounted_apis.to_set + stale = PUBLIC_ALLOWLIST.reject { |api| mounted.include?(api) } + + assert_empty stale, + "PUBLIC_ALLOWLIST names APIs that are no longer mounted in api_root.rb: " \ + "#{stale.join(', ')}. Remove them so the allowlist cannot mask a real gap." + end + + # A mount written in a form this test cannot read (say a multi-line call) would + # otherwise be dropped silently and reported as authenticated. Fail loudly so + # the scanner is widened instead of quietly giving a false all-clear. + def test_every_mount_line_is_parseable + unparsed = mount_lines.grep_v(MOUNT_CALL) + + assert_empty unparsed.map(&:strip), + "These mount lines in api_root.rb could not be parsed, so the auth-coverage " \ + "guard may be skipping an endpoint. Widen MOUNT_CALL to cover them." + end +end diff --git a/test/api/comments/extension_test.rb b/test/api/comments/extension_test.rb index 7c707ab798..6fa5ed47ba 100644 --- a/test/api/comments/extension_test.rb +++ b/test/api/comments/extension_test.rb @@ -9,14 +9,14 @@ def app Rails.application end - def test_extension_application + def test_extension_request_accepts_valid_weeks_and_rejects_out_of_range_weeks unit = FactoryBot.create(:unit) project = unit.projects.first user = project.student td = TaskDefinition.new({ unit_id: unit.id, - tutorial_stream: project.tutorial_enrolments.first.tutorial.tutorial_stream, + tutorial_stream: unit.tutorial_streams.first, name: 'status task change', description: 'status task change test', weighting: 4, @@ -83,7 +83,7 @@ def test_extension_application end # Test that extension requests are not read by main tutor until they are assessed - def test_extension_application + def test_extension_request_remains_unread_by_main_tutor_until_assessed unit = FactoryBot.create(:unit, auto_apply_extension_before_deadline: false) project = unit.projects.first user = project.student diff --git a/test/api/courseflow_api_test.rb b/test/api/courseflow_api_test.rb new file mode 100644 index 0000000000..45e8ada2dd --- /dev/null +++ b/test/api/courseflow_api_test.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CourseflowApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + + def app + Rails.application + end + + setup do + @course = Courseflow::CatalogImporter.import_file!(Rails.root.join('docs/courseflow/sample-catalog.json')) + @student = FactoryBot.create(:user, :student) + @other = FactoryBot.create(:user, :student) + @map = Courseflow::CourseMap.create!(valid_document.merge('user_id' => @student.id)) + add_auth_header_for(user: @student) + end + + def valid_document + { + 'course_id' => @course.id, 'name' => 'My plan', + 'periods' => [{ 'year' => 2026, 'trimester' => 1 }, { 'year' => 2026, 'trimester' => 2 }], + 'slots' => [{ 'unit_code' => 'DEMO101', 'year' => 2026, 'trimester' => 1, 'position' => 1 }] + } + end + + def json_request(method, path, document) + public_send(method, path, JSON.generate(document), { 'CONTENT_TYPE' => 'application/json' }) + end + + def response_body + JSON.parse(last_response.body) + end + + def test_student_catalog_access_needs_no_teaching_unit_permission + get '/api/courseflow/courses' + assert_equal 200, last_response.status, last_response.body + assert_equal [@course.as_catalog], response_body + assert_equal 'private, no-store', last_response.headers['cache-control'] + get "/api/courseflow/courses/#{@course.id}" + assert_equal @course.as_catalog, response_body + get '/api/courseflow/courses/not-an-id' + assert_equal 404, last_response.status + end + + def test_unauthenticated_requests_cannot_read_or_write + header 'Auth-Token', nil + header 'Username', nil + [[:get, '/courses'], [:get, '/maps'], [:get, "/maps/#{@map.id}"], + [:post, '/maps'], [:put, "/maps/#{@map.id}"], [:delete, "/maps/#{@map.id}?lock_version=0"]].each do |method, path| + json_request(method, "/api/courseflow#{path}", valid_document) + assert_equal 419, last_response.status, "#{method} #{path}: #{last_response.body}" + end + end + + def test_every_role_is_scoped_to_its_own_maps_for_all_operations + other_map = Courseflow::CourseMap.create!(valid_document.merge('user_id' => @other.id)) + get '/api/courseflow/maps' + assert_equal [@map.id], response_body.pluck('id') + users = [@other, FactoryBot.create(:user, :tutor), FactoryBot.create(:user, :convenor), FactoryBot.create(:user, :admin)] + users.each do |user| + add_auth_header_for(user: user) + get "/api/courseflow/maps/#{@map.id}" + assert_equal 404, last_response.status + json_request(:put, "/api/courseflow/maps/#{@map.id}", valid_document.merge('lock_version' => 0, 'name' => 'Stolen')) + assert_equal 404, last_response.status + delete "/api/courseflow/maps/#{@map.id}?lock_version=0" + assert_equal 404, last_response.status + get '/api/courseflow/maps' + assert_equal(user == @other ? [other_map.id] : [], response_body.pluck('id')) + end + assert_equal 'My plan', @map.reload.name + end + + def test_create_save_reload_and_delete_preserve_full_plan + json_request(:post, '/api/courseflow/maps', valid_document) + assert_equal 201, last_response.status, last_response.body + created = response_body + assert_equal @student.id, Courseflow::CourseMap.find(created['id']).user_id + assert_equal valid_document['periods'], created['periods'] + assert_equal false, created['complete'] + assert_equal %w[missing_required elective_count], created['issues'].pluck('code') + update = valid_document.merge('name' => 'Renamed', 'lock_version' => created['lock_version'], 'slots' => []) + json_request(:put, "/api/courseflow/maps/#{created['id']}", update) + assert_equal 200, last_response.status, last_response.body + assert_equal created['lock_version'] + 1, response_body['lock_version'] + get "/api/courseflow/maps/#{created['id']}" + assert_equal 'Renamed', response_body['name'] + assert_empty response_body['slots'] + assert_equal update['periods'], response_body['periods'] + delete "/api/courseflow/maps/#{created['id']}?lock_version=#{response_body['lock_version']}" + assert_equal 204, last_response.status, last_response.body + assert_empty last_response.body + assert_not Courseflow::CourseMap.exists?(created['id']) + end + + def test_stale_updates_and_deletes_preserve_newer_plan + @map.update!(name: 'Newer plan') + before = @map.reload.attributes + json_request(:put, "/api/courseflow/maps/#{@map.id}", valid_document.merge('lock_version' => 0, 'slots' => [])) + assert_equal 409, last_response.status, last_response.body + assert_equal before, @map.reload.attributes + delete "/api/courseflow/maps/#{@map.id}?lock_version=0" + assert_equal 409, last_response.status + assert_equal before, @map.reload.attributes + end + + def test_client_cannot_assign_ownership_or_change_course + json_request(:post, '/api/courseflow/maps', valid_document.merge('user_id' => @other.id)) + assert_equal 422, last_response.status + json_request(:put, "/api/courseflow/maps/#{@map.id}", valid_document.merge('lock_version' => 0, 'user_id' => @other.id)) + assert_equal 422, last_response.status + document = @course.as_catalog.except('id').merge('version' => 'next') + other_course = Courseflow::CatalogImporter.import!(document) + json_request(:put, "/api/courseflow/maps/#{@map.id}", valid_document.merge('lock_version' => 0, 'course_id' => other_course.id)) + assert_equal 422, last_response.status, last_response.body + assert_equal @course.id, @map.reload.course_id + assert_equal @student.id, @map.user_id + end + + def test_query_parameters_cannot_reassign_ownership + json_request(:post, "/api/courseflow/maps?user_id=#{@other.id}", valid_document) + assert_equal 422, last_response.status + json_request(:put, "/api/courseflow/maps/#{@map.id}?user_id=#{@other.id}", valid_document.merge('lock_version' => 0)) + assert_equal 422, last_response.status + delete "/api/courseflow/maps/#{@map.id}?lock_version=0&user_id=#{@other.id}" + assert_equal 422, last_response.status + assert_equal @student.id, @map.reload.user_id + end + + def test_lock_version_is_mandatory_and_strictly_typed + [nil, '0', false, -1, 0.5, 2_147_483_648].each do |version| + json_request(:put, "/api/courseflow/maps/#{@map.id}", valid_document.merge('lock_version' => version)) + assert_equal 422, last_response.status, last_response.body + end + json_request(:put, "/api/courseflow/maps/#{@map.id}", valid_document) + assert_equal 422, last_response.status + [nil, '-1', '0.5', 'false', '2147483648'].each do |version| + delete "/api/courseflow/maps/#{@map.id}", version.nil? ? {} : { lock_version: version } + assert_equal 422, last_response.status, last_response.body + end + assert_equal 0, @map.reload.lock_version + end + + def test_json_primitives_extra_fields_and_coerced_types_are_rejected + invalid = [nil, [], true, 'plan', 1, valid_document.merge('name' => 42), valid_document.merge('name' => ' '), + valid_document.merge('course_id' => @course.id.to_s), valid_document.merge('course_id' => @course.id.to_f), + valid_document.merge('periods' => nil), valid_document.merge('slots' => nil), valid_document.merge('extra' => true)] + assert_no_difference 'Courseflow::CourseMap.count' do + invalid.each do |document| + json_request(:post, '/api/courseflow/maps', document) + assert_equal 422, last_response.status, "#{document.inspect}: #{last_response.body}" + end + end + end + + def test_nested_structures_unknown_codes_and_duplicate_slots_are_rejected_atomically + invalid = [ + valid_document.merge('periods' => []), + valid_document.merge('periods' => [nil]), + valid_document.merge('periods' => [{ 'year' => '2026', 'trimester' => 1 }]), + valid_document.merge('periods' => [{ 'year' => 2026.0, 'trimester' => 1 }]), + valid_document.merge('periods' => [{ 'year' => 2026, 'trimester' => true }]), + valid_document.merge('periods' => valid_document['periods'] * 2), + valid_document.merge('slots' => [false]), + valid_document.merge('slots' => valid_document['slots'] * 2) + ] + [{ 'position' => 0 }, { 'position' => '1' }, { 'unit_code' => 'UNKNOWN' }, + { 'trimester' => 3 }, { 'year' => 2201 }, { 'extra' => 'field' }].each do |change| + invalid << valid_document.merge('slots' => [valid_document['slots'][0].merge(change)]) + end + before = @map.attributes + invalid.each do |document| + json_request(:put, "/api/courseflow/maps/#{@map.id}", document.merge('name' => 'Invalid replacement', 'lock_version' => 0)) + assert_equal 422, last_response.status, last_response.body + assert_equal before, @map.reload.attributes + end + end + + def test_unknown_course_excessive_periods_and_slots_are_rejected + invalid = [valid_document.merge('course_id' => 9_999_999_999), + valid_document.merge('name' => 'a' * 201), + valid_document.merge('periods' => (2000..2060).map { |year| { 'year' => year, 'trimester' => 1 } }), + valid_document.merge('slots' => valid_document['slots'] * 241)] + invalid.each do |document| + json_request(:post, '/api/courseflow/maps', document) + assert_equal 422, last_response.status, last_response.body + end + end + + def test_different_units_cannot_occupy_the_same_position_and_units_cannot_repeat + collision = valid_document['slots'][0].merge('unit_code' => 'DEMO201') + repeated_unit = valid_document['slots'][0].merge('position' => 2) + [collision, repeated_unit].each do |slot| + document = valid_document.merge('slots' => valid_document['slots'] + [slot]) + json_request(:post, '/api/courseflow/maps', document) + assert_equal 422, last_response.status, last_response.body + end + end + + def test_malformed_json_is_a_client_error + post '/api/courseflow/maps', '{', { 'CONTENT_TYPE' => 'application/json' } + assert_equal 422, last_response.status, last_response.body + end + + def test_empty_body_is_a_client_error + ['application/json', 'text/plain'].each do |content_type| + post '/api/courseflow/maps', '', { 'CONTENT_TYPE' => content_type } + assert_equal 422, last_response.status, "#{content_type}: #{last_response.body}" + end + assert_equal 1, Courseflow::CourseMap.count + end +end diff --git a/test/api/feedback/feedback_chip_authorization_test.rb b/test/api/feedback/feedback_chip_authorization_test.rb new file mode 100644 index 0000000000..fe7b53f9fe --- /dev/null +++ b/test/api/feedback/feedback_chip_authorization_test.rb @@ -0,0 +1,177 @@ +require 'test_helper' + +class FeedbackChipAuthorizationTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def global_chip + outcome = FactoryBot.create(:learning_outcome, context_type: nil, context_id: nil) + FactoryBot.create(:feedback_template_chip, learning_outcome_id: outcome.id) + end + + def track_usage(chip, params = {}) + post "api/feedback_template_chip/#{chip.id}/track_usage", params + end + + def test_student_cannot_track_feedback_chip_usage + chip = global_chip + student = FactoryBot.create(:user, :student, enrol_in: 1) + + add_auth_header_for user: student + track_usage chip + + assert_equal 403, last_response.status, + "Expected 403 Forbidden - a student should not be able to track feedback chip usage." + assert_equal 0, chip.chip_usages.count + end + + def test_auditor_cannot_track_feedback_chip_usage + chip = global_chip + auditor = FactoryBot.create(:user, :auditor) + + add_auth_header_for user: auditor + track_usage chip + + assert_equal 403, last_response.status + assert_equal 0, chip.chip_usages.count + end + + # tutor_id is no longer read, so a caller who is refused gets the same answer + # whether it names a real user or not, and cannot use it to find user ids. + def test_refused_caller_cannot_tell_which_user_ids_exist + chip = global_chip + student = FactoryBot.create(:user, :student, enrol_in: 1) + existing_user = FactoryBot.create(:user, :tutor) + missing_user_id = User.maximum(:id) + 1000 + + add_auth_header_for user: student + + track_usage chip, tutor_id: existing_user.id + existing_user_response = [last_response.status, last_response.body] + + track_usage chip, tutor_id: missing_user_id + missing_user_response = [last_response.status, last_response.body] + + assert_equal 403, existing_user_response.first + assert_equal existing_user_response, missing_user_response + end + + def test_tutor_can_track_feedback_chip_usage + chip = global_chip + marking_tutor = FactoryBot.create(:user, :tutor) + + add_auth_header_for user: marking_tutor + track_usage chip + + assert_equal 201, last_response.status, + "Expected 201 Created - a tutor should be able to track feedback chip usage. " \ + "Response: #{last_response.body}" + + usage = Feedback::ChipUsage.find_by(feedback_chip: chip, tutor: marking_tutor) + assert_not_nil usage, "Expected a ChipUsage record for the tutor who made the request" + assert_equal 1, usage.usage_count + end + + def test_usage_is_recorded_against_the_caller_not_a_requested_tutor + chip = global_chip + marking_tutor = FactoryBot.create(:user, :tutor) + other_user = FactoryBot.create(:user, :student) + + add_auth_header_for user: marking_tutor + track_usage chip, tutor_id: other_user.id + + assert_equal 201, last_response.status + assert_equal [marking_tutor.id], chip.chip_usages.pluck(:tutor_id) + end + + def test_repeated_use_increments_the_callers_count + chip = global_chip + marking_tutor = FactoryBot.create(:user, :tutor) + + add_auth_header_for user: marking_tutor + 2.times { track_usage chip } + + assert_equal 201, last_response.status + assert_equal 1, chip.chip_usages.count + assert_equal 2, chip.chip_usages.find_by(tutor: marking_tutor).usage_count + end + + # On a global chip a convenor is treated as a tutor, so use a unit chip to + # reach the convenor permission. + def test_convenor_can_track_feedback_chip_usage + unit = FactoryBot.create(:unit, with_students: false) + chip = FactoryBot.create(:feedback_template_chip, learning_outcome_id: unit.learning_outcomes.first.id) + convenor = FactoryBot.create(:user, :convenor) + unit.employ_staff(convenor, Role.convenor) + + add_auth_header_for user: convenor + track_usage chip + + assert_equal 201, last_response.status + assert_equal [convenor.id], chip.chip_usages.pluck(:tutor_id) + end + + def test_admin_can_track_feedback_chip_usage + chip = global_chip + admin = FactoryBot.create(:user, :admin) + + add_auth_header_for user: admin + track_usage chip + + assert_equal 201, last_response.status + assert_equal [admin.id], chip.chip_usages.pluck(:tutor_id) + end + + def test_only_staff_in_the_unit_can_track_its_chips + unit = FactoryBot.create(:unit, with_students: false) + chip = FactoryBot.create(:feedback_template_chip, learning_outcome_id: unit.learning_outcomes.first.id) + unit_tutor = FactoryBot.create(:user, :tutor) + unit.employ_staff(unit_tutor, Role.tutor) + tutor_from_another_unit = FactoryBot.create(:user, :tutor) + + add_auth_header_for user: tutor_from_another_unit + track_usage chip + + assert_equal 403, last_response.status + + add_auth_header_for user: unit_tutor + track_usage chip + + assert_equal 201, last_response.status + assert_equal [unit_tutor.id], chip.chip_usages.pluck(:tutor_id) + end + + def test_observer_only_staff_cannot_track_unit_chip_usage + unit = FactoryBot.create(:unit, with_students: false) + chip = FactoryBot.create(:feedback_template_chip, learning_outcome_id: unit.learning_outcomes.first.id) + observer = FactoryBot.create(:user, :tutor) + unit.employ_staff(observer, Role.tutor) + unit.unit_role_for(observer).update!(observer_only: true) + + add_auth_header_for user: observer + track_usage chip + + assert_equal 403, last_response.status + assert_equal 0, chip.chip_usages.count + end + + def test_observer_only_staff_cannot_track_task_chip_usage + unit = FactoryBot.create(:unit, with_students: false) + task_outcome = FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: unit.task_definitions.first.id) + chip = FactoryBot.create(:feedback_template_chip, learning_outcome_id: task_outcome.id) + observer = FactoryBot.create(:user, :tutor) + unit.employ_staff(observer, Role.tutor) + unit.unit_role_for(observer).update!(observer_only: true) + + add_auth_header_for user: observer + track_usage chip + + assert_equal 403, last_response.status + assert_equal 0, chip.chip_usages.count + end +end diff --git a/test/api/overseer_steps_api_test.rb b/test/api/overseer_steps_api_test.rb new file mode 100644 index 0000000000..e63f11a578 --- /dev/null +++ b/test/api/overseer_steps_api_test.rb @@ -0,0 +1,104 @@ +require 'test_helper' + +class OverseerStepsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::OverseerTestHelper + + def app + Rails.application + end + + def setup + setup_overseer_enabled + + @unit = FactoryBot.create(:unit, with_students: false) + @task_definition = @unit.task_definitions.first + @other_task_definition = @unit.task_definitions.where.not(id: @task_definition.id).first + + @owner = FactoryBot.create(:user, :student) + @owner_project = @unit.enrol_student(@owner, nil) + + @other_student = FactoryBot.create(:user, :student) + @other_project = @unit.enrol_student(@other_student, nil) + + @tutor = FactoryBot.create(:user, :tutor) + @unit.employ_staff(@tutor, Role.tutor) + + @overseer_step = OverseerStep.create!( + task_definition: @task_definition, + name: 'compile', + display_name: 'Compile', + step_type: 'build', + timeout: 30, + sort_order: 0 + ) + + @assessment = create_assessment_for(@owner_project) + @result = @assessment.overseer_step_results.first + end + + # + # Create an overseer assessment, with one step result, for the given project + # + def create_assessment_for(project) + task = project.task_for_task_definition(@task_definition) + submission_history = FactoryBot.create(:submission_history, task: task) + assessment = FactoryBot.create(:overseer_assessment, submission_history: submission_history) + + OverseerStepResult.create!( + overseer_assessment: assessment, + overseer_step: @overseer_step, + exit_status: 0, + pass: true, + feedback_message: 'All good' + ) + + assessment + end + + def results_url(project, assessment, task_definition = @task_definition) + "/api/projects/#{project.id}/task_definitions/#{task_definition.id}/overseer_assessments_results/#{assessment.id}" + end + + def test_student_can_get_results_for_their_own_overseer_assessment + add_auth_header_for(user: @owner) + + get results_url(@owner_project, @assessment) + + assert_equal 200, last_response.status, last_response.body + assert_equal 1, last_response_body.count, last_response.body + assert_equal @result.id, last_response_body.first['id'] + end + + def test_student_cannot_get_results_for_another_students_overseer_assessment + add_auth_header_for(user: @other_student) + + get results_url(@other_project, @assessment) + + assert_equal 404, last_response.status, last_response.body + refute last_response.body.include?(@result.feedback_message), last_response.body + refute last_response.body.include?("\"id\":#{@result.id}"), last_response.body + end + + def test_student_cannot_get_results_under_a_different_task_definition + add_auth_header_for(user: @owner) + + get results_url(@owner_project, @assessment, @other_task_definition) + + assert_equal 404, last_response.status, last_response.body + refute last_response.body.include?(@result.feedback_message), last_response.body + refute last_response.body.include?("\"id\":#{@result.id}"), last_response.body + end + + def test_tutor_can_get_results_for_a_students_overseer_assessment + add_auth_header_for(user: @tutor) + + get results_url(@owner_project, @assessment) + + assert_equal 200, last_response.status, last_response.body + assert_equal 1, last_response_body.count, last_response.body + assert_equal @result.id, last_response_body.first['id'] + end +end diff --git a/test/api/project_history_test.rb b/test/api/project_history_test.rb new file mode 100644 index 0000000000..240c795353 --- /dev/null +++ b/test/api/project_history_test.rb @@ -0,0 +1,100 @@ +require 'test_helper' + +class ProjectHistoryTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_history_requires_authentication + clear_auth_header + + get '/api/projects/history' + + assert_equal 419, last_response.status + assert_not last_response_body.key?('hasProjects') + end + + def test_user_without_projects_has_no_history + student = FactoryBot.create(:user, :student) + add_auth_header_for(user: student) + + get '/api/projects/history' + + assert_equal 200, last_response.status + assert_equal({ 'hasProjects' => false }, last_response_body) + end + + def test_active_project_counts_as_history + student = FactoryBot.create(:user, :student, enrol_in: 1) + add_auth_header_for(user: student) + + get '/api/projects/history' + + assert_equal 200, last_response.status + assert_equal({ 'hasProjects' => true }, last_response_body) + + get '/api/projects' + assert_equal 200, last_response.status + assert_equal [student.projects.first.id], last_response_body.pluck('id') + end + + def test_inactive_unit_counts_as_history_without_changing_project_listing + student = FactoryBot.create(:user, :student, enrol_in: 1) + student.projects.first.unit.update!(active: false) + add_auth_header_for(user: student) + + get '/api/projects/history' + + assert_equal 200, last_response.status + assert_equal({ 'hasProjects' => true }, last_response_body) + + get '/api/projects' + assert_equal 200, last_response.status + assert_empty last_response_body + + get '/api/projects', include_inactive: true + assert_equal 200, last_response.status + assert_equal [student.projects.first.id], last_response_body.pluck('id') + end + + [true, false].each do |active| + define_method("test_withdrawn_project_in_#{active ? 'active' : 'inactive'}_unit_counts_as_history") do + student = FactoryBot.create(:user, :student, enrol_in: 1) + project = student.projects.first + project.update!(enrolled: false) + project.unit.update!(active: active) + add_auth_header_for(user: student) + + get '/api/projects/history' + + assert_equal 200, last_response.status + assert_equal({ 'hasProjects' => true }, last_response_body) + + get '/api/projects', include_inactive: true + assert_equal 200, last_response.status + assert_empty last_response_body + end + end + + def test_query_parameters_cannot_select_another_users_history + student = FactoryBot.create(:user, :student) + other_student = FactoryBot.create(:user, :student, enrol_in: 1) + add_auth_header_for(user: student) + + get '/api/projects/history', user_id: other_student.id, username: other_student.username + + assert_equal 200, last_response.status + assert_equal({ 'hasProjects' => false }, last_response_body) + + add_auth_header_for(user: other_student) + + get '/api/projects/history', user_id: student.id, username: student.username + + assert_equal 200, last_response.status + assert_equal({ 'hasProjects' => true }, last_response_body) + end +end diff --git a/test/api/settings_test.rb b/test/api/settings_test.rb index 2a922d1c93..fef47c4ec1 100644 --- a/test/api/settings_test.rb +++ b/test/api/settings_test.rb @@ -1,48 +1,120 @@ require 'test_helper' require 'json' -class SettingTest < ActiveSupport::TestCase - include Rack::Test::Methods - include TestHelpers::AuthHelper - include TestHelpers::JsonHelper +class SettingsTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper - def app - Rails.application + def app + Rails.application + end + + def test_public_settings_are_available_without_authentication + clear_auth_header + + get '/api/settings/public' + + assert_equal 200, last_response.status + assert_equal( + Doubtfire::Application.config.institution[:product_name], + last_response_body['externalName'] + ) + assert_equal( + Doubtfire::Application.config.institution[:has_logo], + last_response_body['hasLogo'] + ) + assert_equal( + Doubtfire::Application.config.institution[:logo_url], + last_response_body['logoUrl'] + ) + assert_equal( + Doubtfire::Application.config.institution[:logo_link_url], + last_response_body['logoLinkUrl'] + ) + + assert_equal( + %w[externalName hasLogo logoLinkUrl logoUrl].sort, + last_response_body.keys.sort + ) + end + + def test_authenticated_settings_reject_unauthenticated_requests + clear_auth_header + + get '/api/settings' + + assert_equal 419, last_response.status + assert_equal( + 'No authentication details provided. Authentication is required to access this resource.', + last_response_body['error'] + ) + end + + def test_authenticated_settings_are_available_with_authentication + add_auth_header_for + + get '/api/settings' + + assert_equal 200, last_response.status + assert_equal( + Doubtfire::Application.config.overseer_enabled, + last_response_body['overseerEnabled'] + ) + assert_equal TurnItIn.enabled?, last_response_body['tiiEnabled'] + assert_equal D2lIntegration.enabled?, last_response_body['d2lEnabled'] + assert_equal Doubtfire::Application.config.tutorial_enabled, last_response_body['tutorialEnabled'] + + assert_equal( + %w[d2lEnabled overseerEnabled pushEnabled tiiEnabled tutorialEnabled vapidPublicKey].sort, + last_response_body.keys.sort + ) + end + + def test_tutorial_flag_uses_the_existing_environment_parser + original_env = ENV.fetch('TUTORIAL_ENABLED', nil) + original_config = Doubtfire::Application.config.tutorial_enabled + # Re-evaluate the boot assignment so this checks the real parser without + # restarting Rails (and its database connections) for each value. + assignment = Rails.root.join('config/application.rb').read.lines.find do |line| + line.strip.start_with?('config.tutorial_enabled =') end + assert assignment, 'tutorial rollout gate must be configured at boot' + add_auth_header_for + + { nil => false, '' => false, '0' => false, 'false' => false, + 'FALSE' => false, 'true' => false, '1' => true, '2' => true }.each do |value, expected| + ENV['TUTORIAL_ENABLED'] = value + Doubtfire::Application.class_eval(assignment) + + get '/api/settings' - # Get config details - def test_get_config_details - expected_product_name = Doubtfire::Application.config.institution[:product_name] - - # Perform the GET - get '/api/settings' - - # Set returned details - returned_mes = last_response_body['externalName'] - - # Check if the call succeeds - assert_equal 200, last_response.status - # Check returned details match as expected - assert_equal expected_product_name, returned_mes - end - - # Get privacy policy details - def test_get_privacy_policy_details - expected_privacy = Doubtfire::Application.config.institution[:privacy] - expected_plagiarism = Doubtfire::Application.config.institution[:plagiarism] - - # Perform the GET - get '/api/settings/privacy' - - # Set two returned details - returned_privacy = last_response_body['privacy'] - returned_plagiarism = last_response_body['plagiarism'] - - # Check if the call succeeds - assert_equal 200, last_response.status - - # Check returned details match as expected - assert_equal expected_privacy, returned_privacy - assert_equal expected_plagiarism, returned_plagiarism + assert_equal 200, last_response.status + assert_equal expected, last_response_body['tutorialEnabled'], "TUTORIAL_ENABLED=#{value.inspect}" end + ensure + ENV['TUTORIAL_ENABLED'] = original_env + Doubtfire::Application.config.tutorial_enabled = original_config + end + + def test_privacy_policy_is_available_without_authentication + clear_auth_header + + get '/api/settings/privacy' + + assert_equal 200, last_response.status + assert_equal( + Doubtfire::Application.config.institution[:privacy], + last_response_body['privacy'] + ) + assert_equal( + Doubtfire::Application.config.institution[:plagiarism], + last_response_body['plagiarism'] + ) + + assert_equal( + %w[plagiarism privacy].sort, + last_response_body.keys.sort + ) + end end diff --git a/test/api/task_statuses_api_test.rb b/test/api/task_statuses_api_test.rb new file mode 100644 index 0000000000..c443494039 --- /dev/null +++ b/test/api/task_statuses_api_test.rb @@ -0,0 +1,36 @@ +require 'test_helper' + +class TaskStatusesApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + # The endpoint is public, so no auth header is added on purpose. + def test_get_all_task_statuses + get '/api/task_statuses' + + assert_equal 200, last_response.status, last_response_body + + body = JSON.parse(last_response.body) + assert_equal TaskStatus.count, body.length + assert_equal 15, body.length + + # Every row carries the id, its key, a name and a description, and the key + # must be the one the model derives for that id. + body.each do |row| + assert row.key?('id') + assert row.key?('key') + assert row.key?('name') + assert row.key?('description') + assert_equal TaskStatus.id_to_key(row['id']).to_s, row['key'] + end + + # Ordered by id ascending. + ids = body.map { |row| row['id'] } + assert_equal ids.sort, ids + end +end diff --git a/test/api/tci_33_test.rb b/test/api/tci_33_test.rb new file mode 100644 index 0000000000..835b03087a --- /dev/null +++ b/test/api/tci_33_test.rb @@ -0,0 +1,24 @@ +require 'minitest/autorun' +require 'pathname' + +class Tci33Test < Minitest::Test + ROOT = Pathname.new(__dir__).join('..', '..').expand_path + + def test_dead_rspec_scaffolding_is_removed + refute File.exist?(ROOT.join('.rspec')), + '.rspec should not exist' + refute File.exist?(ROOT.join('test/channels/application_cable/connection_test.rb')), + 'dead ActionCable connection test should not exist' + refute File.exist?(ROOT.join('test/integration/.keep')), + 'unused integration test placeholder should not exist' + end + + def test_readme_documents_the_minitest_workflow + readme = File.read(ROOT.join('README.md')) + + assert_includes readme, 'Minitest' + assert_includes readme, 'test/models' + assert_includes readme, 'test/api' + assert_includes readme, 'rake test' + end +end diff --git a/test/api/tii/tii_hook_test.rb b/test/api/tii/tii_hook_test.rb index fbba5991ff..9166cd0f5e 100644 --- a/test/api/tii/tii_hook_test.rb +++ b/test/api/tii/tii_hook_test.rb @@ -80,80 +80,7 @@ def test_submission_webhook end # Test the similarity webhook - def test_similarity_webhook - task = FactoryBot.create(:task) - user = task.project.user - - task.task_definition.upload_requirements = [ - { - "key" => 'file0', - "name" => 'Document 1', - "type" => 'document', - "tii_check" => true, - "tii_pct" => 35 - } - ] - - subm = TiiSubmission.create!( - submission_id: "e884f478-9757-41c7-80da-37b94ebb2838", - status: 'similarity_report_requested', - task: task, - filename: 'test.doc', - idx: 0, - submitted_at: Time.zone.now, - submitted_by: task.project.user - ) - - # destroy will trigger delete of submission - delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) - - data = TCAClient::SimilarityCompleteWebhookRequest.new( - "submission_id" => "e884f478-9757-41c7-80da-37b94ebb2838", - "overall_match_percentage" => 15, - "internet_match_percentage" => 12, - "publication_match_percentage" => 10, - "submitted_works_match_percentage" => 0, - "status" => "COMPLETE", - "time_requested" => "2017-11-06T19:14:31.828Z", - "time_generated" => "2017-11-06T19:14:45.993Z", - "top_source_largest_matched_word_count" => 193, - "top_matches" => [ - { - "percentage" => 100.0, - "submission_id" => "883fbb3a-2825-4a2a-8d24-d52e40673772", - "source_type" => "SUBMITTED_WORK", - "matched_word_count_total" => 598, - "submitted_date" => "2021-05-05", - "institution_name" => "Tii Auto TCA Platinum Test Tenant", - "name" => "Tii Auto TCA Platinum Test Tenant on 2021-05-05" - } - ], - "metadata" => { - "custom" => "{\"Type\":\"Final Paper\"}" - } - ) - - # puts data.to_json - - digest = OpenSSL::Digest.new('sha256') - hmac = OpenSSL::HMAC.hexdigest(digest, ENV.fetch('TCA_SIGNING_KEY', nil), data.to_json) - - # Add signature details - header "X-Turnitin-Signature", hmac - header "X-Turnitin-EventType", "SIMILARITY_COMPLETE" - - post_json '/api/tii_hook', data - - assert_equal 201, last_response.status, last_response_body - assert_equal :complete_low_similarity, subm.reload.status_sym - - task.unit.destroy! - end - - # Test the similarity webhook - def test_similarity_webhook + def test_similarity_webhook_records_and_returns_low_similarity_without_local_match task = FactoryBot.create(:task) user = task.project.user diff --git a/test/api/units_csv_audit_test.rb b/test/api/units_csv_audit_test.rb new file mode 100644 index 0000000000..88b1b81cf1 --- /dev/null +++ b/test/api/units_csv_audit_test.rb @@ -0,0 +1,92 @@ +require 'test_helper' +require 'tempfile' + +class UnitsCsvAuditTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::TestFileHelper + + def app + Rails.application + end + + # Swap Rails.logger for a StringIO-backed logger for the duration of the + # block and return everything written to it. The endpoints log through + # Rails.logger explicitly, so the swap captures their lines. + def with_captured_rails_log + io = StringIO.new + original = Rails.logger + # Wrap in TaggedLogging so the request's own Rails::Rack::Logger.tagged call + # still works while we are capturing. + Rails.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(io)) + begin + yield + ensure + Rails.logger = original + end + io.string + end + + def convenor_for(unit) + convenor = FactoryBot.create :user, :convenor + ur = unit.employ_staff convenor, Role.convenor + unit.update(main_convenor: ur) + convenor + end + + def test_bulk_withdraw_writes_an_audit_line + unit = FactoryBot.create :unit + convenor = convenor_for(unit) + student = unit.active_projects.first.user + + csv = Tempfile.new(['withdraw', '.csv']) + csv.write("unit_code,username\n#{unit.code},#{student.username}\n") + csv.rewind + + add_auth_header_for(user: convenor) + + log = with_captured_rails_log do + post "/api/csv/units/#{unit.id}/withdraw", + file: Rack::Test::UploadedFile.new(csv.path, 'text/csv') + end + + assert_not_equal 403, last_response.status, last_response.body + audit_line = log.lines.find { |line| line.include?('units.bulk_withdraw') } + audit = JSON.parse(audit_line[audit_line.index('{')..]) + assert_equal convenor.id, audit.fetch('user_id') + assert_equal unit.id, audit.fetch('unit_id') + assert_equal 1, audit.fetch('withdrawn_count') + assert_equal [unit.projects.find_by!(user: student).id], audit.fetch('project_ids') + assert_not_includes audit_line, convenor.username + ensure + csv&.close! + end + + def test_class_csv_export_writes_an_audit_line + unit = FactoryBot.create :unit + convenor = convenor_for(unit) + + add_auth_header_for(user: convenor) + + log = with_captured_rails_log do + get "/api/csv/units/#{unit.id}" + end + + assert_equal 200, last_response.status, last_response.body + audit_line = log.lines.find { |line| line.include?('units.csv_export') } + audit = JSON.parse(audit_line[audit_line.index('{')..]) + assert_equal convenor.id, audit.fetch('user_id') + assert_equal unit.id, audit.fetch('unit_id') + assert_not_includes audit_line, convenor.username + end + + def test_denied_export_writes_no_success_audit + unit = FactoryBot.create(:unit) + add_auth_header_for(user: FactoryBot.create(:user, :student)) + log = with_captured_rails_log { get "/api/csv/units/#{unit.id}" } + assert_equal 403, last_response.status + assert_not_includes log, 'units.csv_export' + end + +end diff --git a/test/channels/application_cable/connection_test.rb b/test/channels/application_cable/connection_test.rb deleted file mode 100644 index c10c504f08..0000000000 --- a/test/channels/application_cable/connection_test.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "test_helper" - -class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase - # def test_connects_with_cookies - # cookies.signed[:user_id] = 42 - # - # connect - # - # assert_equal connection.user_id, "42" - # end -end diff --git a/test/config/database_yml_test.rb b/test/config/database_yml_test.rb new file mode 100644 index 0000000000..30340e4e17 --- /dev/null +++ b/test/config/database_yml_test.rb @@ -0,0 +1,24 @@ +require 'test_helper' + +class DatabaseYmlTest < ActiveSupport::TestCase + # Load config/database.yml the same way Rails does, through ERB, and assert + # every deployed environment pins the same utf8mb4 client charset. Without it + # production and staging inherit whatever the server image defaults to, so a + # comment containing an emoji or a CJK character saves in test and raises + # Mysql2::Error: Incorrect string value in production. + def database_config + raw = File.read(Rails.root.join('config', 'database.yml')) + YAML.safe_load(ERB.new(raw).result, aliases: true) + end + + def test_every_environment_sets_utf8mb4_encoding_and_collation + config = database_config + + %w[development test staging production].each do |env| + assert_equal 'utf8mb4', config[env]['encoding'], + "#{env} must pin the utf8mb4 client encoding" + assert_equal 'utf8mb4_general_ci', config[env]['collation'], + "#{env} must pin the utf8mb4_general_ci collation" + end + end +end diff --git a/test/config/pdfgen_config_test.rb b/test/config/pdfgen_config_test.rb new file mode 100644 index 0000000000..986b9c4fe1 --- /dev/null +++ b/test/config/pdfgen_config_test.rb @@ -0,0 +1,43 @@ +require 'test_helper' + +class PdfgenConfigTest < ActiveSupport::TestCase + ENV_NAME = 'DF_MAX_PDF_GEN_PROCESSES'.freeze + + def teardown + ENV.delete(ENV_NAME) + end + + def parse(raw) + if raw.nil? + ENV.delete(ENV_NAME) + else + ENV[ENV_NAME] = raw + end + Doubtfire::Application.fetch_positive_integer_env(ENV_NAME, default: 2, max: 100) + end + + def test_uses_the_default_when_unset + assert_equal 2, parse(nil) + end + + def test_parses_a_configured_value + assert_equal 5, parse('5') + end + + def test_result_is_an_integer_not_a_string + # Regression: a String here made the generator compare Integer with String and raise. + assert_kind_of Integer, parse('3') + end + + def test_rejects_a_non_integer_value + assert_raises(RuntimeError) { parse('three') } + end + + def test_rejects_a_value_below_one + assert_raises(RuntimeError) { parse('0') } + end + + def test_rejects_a_value_above_the_maximum + assert_raises(RuntimeError) { parse('101') } + end +end diff --git a/test/config/release_configuration_test.rb b/test/config/release_configuration_test.rb new file mode 100644 index 0000000000..75433f465f --- /dev/null +++ b/test/config/release_configuration_test.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReleaseConfigurationTest < Minitest::Test + RUBY_BASE = 'ruby:3.4.10-bookworm@sha256:56e0c9fdbf64d090e45072d32f0d3be7f2e392e733444f7d176a50881e6c325a' + + def test_production_application_images_are_pinned_and_daemon_free + api = read('deployApi.Dockerfile') + worker = read('deployAppSvr.Dockerfile') + + assert_match(/^FROM #{Regexp.escape(RUBY_BASE)}$/m, api) + assert_match(/^FROM #{Regexp.escape(RUBY_BASE)}$/m, worker) + assert_includes read('Gemfile.lock'), 'ruby 3.4.10p104' + assert_match( + /^FROM docker:28\.5\.2-cli@sha256:[0-9a-f]{64} AS docker_cli$/m, + worker + ) + + [api, worker].each do |dockerfile| + assert_equal false, /\b(?:docker-ce|containerd\.io)\b/.match?(dockerfile) + assert_equal false, /^\s*redis\s*\\?$/m.match?(dockerfile) + assert_match(/bundle config set deployment true/, dockerfile) + end + + assert_equal false, /db:migrate/.match?(api) + assert_match( + /CMD \["bundle", "exec", "rails", "server", "-b", "0\.0\.0\.0"\]/, + api + ) + end + + def test_docker_build_context_excludes_local_credentials + dockerignore = read('.dockerignore').lines.map(&:strip) + required_patterns = %w[ + .docker + .bundle + .env + .env.* + .npmrc + .gem/credentials + .ssh + .aws + .config/gcloud + config/master.key + config/credentials + config/credentials.yml.enc + **/*.key + **/*.pem + **/*.p12 + **/*.pfx + **/*.jks + **/*.keystore + ] + + required_patterns.each { |pattern| assert_includes dockerignore, pattern } + assert_includes dockerignore, '!.env.example' + end + + def test_helper_images_pin_bases_and_verify_downloads + texlive = read('texlive.Dockerfile') + jplag = read('jplag.Dockerfile') + + texlive.scan(/^FROM (\S+)/).flatten.each do |base| + assert_match(/@sha256:[0-9a-f]{64}\z/, base) + end + assert_includes texlive, '/historic/systems/texlive/2025/tlnet-final' + assert_includes texlive, 'sha512sum --check' + assert_includes texlive, 'tlmgr --repository "$TL_MIRROR" install' + assert_includes texlive, 'pdfmanagement-testphase' + assert_equal false, /^\s*pdfmanagement\s*\\$/m.match?(texlive) + assert_includes texlive, 'kpsewhich pdfmanagement-testphase.sty' + assert_includes texlive, '--jobname=pdfmanagement-smoke' + + assert_match(/^FROM alpine:3\.23\.3@sha256:[0-9a-f]{64}$/m, jplag) + assert_includes jplag, 'JPLAG_SHA256=' + assert_includes jplag, 'sha256sum -c -' + end + + def test_release_lock_stays_above_known_security_floors + minimum_versions = { + 'concurrent-ruby' => '1.3.7', # GHSA-h8w8-99g7-qmvj + 'crass' => '1.0.7', # GHSA-6wmf-3r64-vcwv + 'net-imap' => '0.5.14', # GHSA-vcgp-9326-pqcp + 'nokogiri' => '1.19.3', # GHSA-c4rq-3m3g-8wgx and GHSA-353f-x4gh-cqq8 + 'uri' => '1.0.4', # GHSA-j4pr-3wm6-xx2r + 'websocket-driver' => '0.8.2', # GHSA-2x63-gw47-w4mm + 'yard' => '0.9.42' # CVE-2026-41493 (development/test) + } + + minimum_versions.each do |name, minimum| + versions = locked_versions(name) + assert_operator versions.length, :>, 0, "#{name} must remain in Gemfile.lock" + versions.each do |version| + assert_operator version, :>=, Gem::Version.new(minimum), "#{name} #{version} is below #{minimum}" + end + end + end + + def test_test_database_schema_fingerprint_stays_stable + schema = read('db/schema.rb') + migration = read('db/migrate/20260824000002_ensure_target_grade_changed_at_default.rb') + workflow = read('.github/workflows/push.yml') + database_preparation = read('script/prepare_test_database.sh') + + assert_includes schema, 'default: -> { "current_timestamp(6)" }' + assert_includes migration, "-> { 'CURRENT_TIMESTAMP(6)' }" + assert_includes workflow, 'script/prepare_test_database.sh' + assert_includes workflow, 'git diff --exit-code -- db/schema.rb' + assert_includes database_preparation, "abort 'db:populate created no units' unless Unit.exists?" + assert_includes database_preparation, 'logical lanes import it directly' + end + + def test_unit_test_workflow_fits_runner_slots_and_uses_the_source_free_ci_image + workflow = read('.github/workflows/push.yml') + dockerfile = read('Dockerfile') + bake = read('docker-bake.ci.hcl') + shard_planner = read('script/plan_test_shard_worker.rb') + seeded_database_key = workflow.lines.find { |line| line.include?('key: seeded-test-database') } + + expected_workers = (1..5).to_a.join(', ') + assert_includes workflow, "worker: [#{expected_workers}]" + assert_includes workflow, 'TEST_SHARD_COUNT: "20"' + assert_includes workflow, 'TEST_SHARD_WORKER_COUNT: "5"' + assert_includes workflow, "CI_IMAGE_CACHE_WRITE: ${{ github.event_name != 'pull_request' }}" + assert_includes workflow, 'SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE: "true"' + assert_includes workflow, '--env SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE' + assert_includes workflow, 'DOCKER_BUILD_RECORD_UPLOAD: "false"' + assert_includes workflow, 'DOCKER_BUILD_SUMMARY: "false"' + assert_equal false, workflow.include?('max-parallel:') + assert_includes workflow, 'Build test images concurrently' + assert_includes workflow, 'docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b' + assert_includes workflow, 'targets: ${{ steps.plan_shard.outputs.bake_targets }}' + assert_includes workflow, 'load: true' + assert_includes workflow, 'TEST_SHARD_SELECTOR_INVENTORY=tmp/test-selector-inventory.txt' + assert_includes workflow, 'selector_inventory_path=tmp/all-test-shard-manifests/test-selector-inventory.txt' + assert_includes shard_planner, 'api_cache_writer: cache_write_enabled && worker_number == worker_count' + assert_equal 1, workflow.scan('actions/checkout@').length + assert_equal false, workflow.include?('docker/build-push-action') + assert_equal false, workflow.include?('maus007/docker-run-action-fork') + assert_includes bake, 'target = "ci"' + assert_includes bake, 'tags = ["doubtfire-api-ci:local"]' + assert_includes bake, 'target "api-cache-writer"' + assert_includes bake, 'target "texlive-cache-writer"' + assert_includes bake, 'target "jplag-cache-writer"' + assert_includes bake, 'tags = ["doubtfire-texlive-development:local"]' + assert_includes bake, 'tags = ["doubtfire-jplag-development:local"]' + assert_instance_of String, seeded_database_key + assert_includes seeded_database_key, "'docker-bake.ci.hcl'" + + ci_stage = dockerfile.index("FROM dependencies AS ci\n") + development_stage = dockerfile.index("FROM dependencies AS development\n") + source_copy = dockerfile.index("COPY . .\n") + assert_instance_of Integer, ci_stage + assert_instance_of Integer, development_stage + assert_instance_of Integer, source_copy + assert_operator ci_stage, :<, development_stage + assert_operator development_stage, :<, source_copy + end + + def test_development_compose_has_no_literal_institution_credential + compose = read('docker-compose.yml') + + assert_match(/DF_SECRET_KEY_AAF:\s*\$\{DF_SECRET_KEY_AAF:-\}/, compose) + assert_equal false, %r{https?://[^\s$]*(?:aaf\.edu\.au|deakin\.edu\.au)}i.match?(compose) + end + + def test_production_image_workflow_actions_are_immutable + all_workflows = Rails.root.join('.github/workflows').children + all_workflows.select! { |path| %w[.yml .yaml].include?(path.extname) } + all_workflows.map!(&:read) + + all_workflows.each do |workflow| + workflow.each_line.grep(/^\s*-?\s*uses:/).each do |line| + assert_match(/@[0-9a-f]{40}(?:\s+#.*)?$/, line) + end + end + + release_workflows = [ + read('.github/workflows/production-images.yml'), + read('.github/workflows/deployment.yml') + ] + + release_workflow = release_workflows.last + assert_operator release_workflow.scan(/^\s*sbom:\s*true$/).length, :>=, 2 + assert_operator release_workflow.scan(/^\s*provenance:\s*mode=max$/).length, :>=, 2 + assert_equal 3, release_workflow.scan(/^\s*push:\s*false$/).length + assert_equal false, release_workflow.include?('docker/login-action') + assert_equal false, release_workflow.include?('DOCKERHUB_TOKEN') + + validation_workflow = release_workflows.first + %w[deployApi.Dockerfile deployAppSvr.Dockerfile texlive.Dockerfile jplag.Dockerfile].each do |dockerfile| + assert_includes validation_workflow, dockerfile + end + assert_equal false, validation_workflow.include?('paths:') + end + + private + + def read(path) + Rails.root.join(path).read + end + + def locked_versions(name) + read('Gemfile.lock') + .scan(/^ #{Regexp.escape(name)} \((\d+(?:\.\d+)+)(?:-[^)]+)?\)$/) + .flatten + .map { |version| Gem::Version.new(version) } + .uniq + end +end diff --git a/test/config/rubocop_configuration_test.rb b/test/config/rubocop_configuration_test.rb new file mode 100644 index 0000000000..4781816e1e --- /dev/null +++ b/test/config/rubocop_configuration_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'rubocop' + +class RubocopConfigurationTest < Minitest::Test + ROOT = File.expand_path('../..', __dir__) + CONFIG_PATH = File.join(ROOT, '.rubocop.yml') + + def test_test_sources_are_discovered_with_the_effective_configuration + config_store = RuboCop::ConfigStore.new + config_store.options_config = CONFIG_PATH + targets = RuboCop::TargetFinder.new(config_store).target_files_in_dir(File.join(ROOT, 'test')) + + %w[test/api/auth_test.rb test/models/task_test.rb test/factories/users_factory.rb].each do |path| + assert_includes targets, File.join(ROOT, path), "#{path} must be linted" + end + end + + def test_required_plugins_and_their_cops_are_loaded + plugins = rubocop_config.loaded_plugins.map { |plugin| plugin.about.name } + + %w[rubocop-minitest rubocop-factory_bot].each do |plugin| + assert_includes plugins, plugin + end + + %w[Minitest/AssertEqual FactoryBot/CreateList].each do |cop| + assert_includes RuboCop::Cop::Registry.global.names, cop + assert rubocop_config.cop_enabled?(cop), "#{cop} must be enabled" + end + end + + def test_existing_production_cops_remain_enabled + %w[Lint/UnusedBlockArgument Layout/SpaceInsideHashLiteralBraces Style/PercentLiteralDelimiters].each do |cop| + assert rubocop_config.cop_enabled?(cop), "#{cop} must remain enabled for production code" + end + end + + def test_existing_production_complexity_limits_are_not_relaxed + { 'Metrics/AbcSize' => 153, 'Metrics/MethodLength' => 140 }.each do |cop, maximum| + assert rubocop_config.cop_enabled?(cop), "#{cop} must remain enabled for production code" + assert_operator rubocop_config.for_cop(cop).fetch('Max'), :<=, maximum, + "#{cop} must retain its production limit" + end + end + + def test_existing_quoted_symbol_style_is_preserved + assert rubocop_config.cop_enabled?('Style/QuotedSymbols') + assert_equal 'double_quotes', rubocop_config.for_cop('Style/QuotedSymbols').fetch('EnforcedStyle') + end + + private + + def rubocop_config + @rubocop_config ||= RuboCop::ConfigLoader.configuration_from_file(CONFIG_PATH) + end +end diff --git a/test/config/student_import_weeks_before_test.rb b/test/config/student_import_weeks_before_test.rb new file mode 100644 index 0000000000..6dde968a79 --- /dev/null +++ b/test/config/student_import_weeks_before_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class StudentImportWeeksBeforeTest < ActiveSupport::TestCase + def application_rb_source + File.read(Rails.root.join('config', 'application.rb')) + end + + def test_prefers_correct_spelling_with_fallback_to_misspelled_variable + assert_match( + /ENV\.fetch\('DF_IMPORT_STUDENTS_WEEKS_BEFORE'\)\s*\{\s*ENV\.fetch\('DF_IMPORT_STUDENTS_WEEKS_BEFPRE',\s*1\)\s*\}/, + application_rb_source, + "Expected config/application.rb to prefer DF_IMPORT_STUDENTS_WEEKS_BEFORE, falling back to the misspelled DF_IMPORT_STUDENTS_WEEKS_BEFPRE" + ) + end + + def test_no_longer_reads_only_the_misspelled_variable + refute_match( + /ENV\.fetch\('DF_IMPORT_STUDENTS_WEEKS_BEFPRE',\s*1\)\.to_f\s*\*\s*1\.week/, + application_rb_source, + "config/application.rb should not read DF_IMPORT_STUDENTS_WEEKS_BEFPRE as the sole/primary source" + ) + end +end diff --git a/test/controllers/readiness_controller_test.rb b/test/controllers/readiness_controller_test.rb new file mode 100644 index 0000000000..f0e935f629 --- /dev/null +++ b/test/controllers/readiness_controller_test.rb @@ -0,0 +1,28 @@ +require 'test_helper' +require 'minitest/mock' + +class ReadinessControllerTest < ActionDispatch::IntegrationTest + StaticReadinessCheck = Struct.new(:result) do + def ready? + result + end + end + + test 'returns ok without authentication when dependencies are ready' do + ReadinessCheck.stub(:new, StaticReadinessCheck.new(true)) do + get '/readiness' + end + + assert_response :ok + assert_empty response.body + end + + test 'returns only service unavailable when a dependency is down' do + ReadinessCheck.stub(:new, StaticReadinessCheck.new(false)) do + get '/readiness' + end + + assert_response :service_unavailable + assert_empty response.body + end +end diff --git a/test/dx_a02_test.rb b/test/dx_a02_test.rb new file mode 100644 index 0000000000..5c3d0f0ce3 --- /dev/null +++ b/test/dx_a02_test.rb @@ -0,0 +1,14 @@ +require 'test_helper' + +class DxA02Test < ActiveSupport::TestCase + test 'dead config and scaffold files are removed' do + refute File.exist?(Rails.root.join('.rspec')), '.rspec should have been deleted' + refute File.exist?(Rails.root.join('.overcommit.yml')), '.overcommit.yml should have been deleted' + refute File.exist?(Rails.root.join('FETCH_HEAD')), 'FETCH_HEAD should have been deleted' + refute File.exist?(Rails.root.join('docs', 'README_FOR_APP')), 'docs/README_FOR_APP should have been deleted' + end + + test 'User.default no longer exists' do + assert_raises(NoMethodError) { User.default } + end +end \ No newline at end of file diff --git a/test/integration/.keep b/test/integration/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/lib/production_boot_guard_test.rb b/test/lib/production_boot_guard_test.rb new file mode 100644 index 0000000000..ba1ff5fa39 --- /dev/null +++ b/test/lib/production_boot_guard_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'active_support' +require 'active_support/test_case' +require 'active_support/core_ext/enumerable' +require 'open3' +require 'rbconfig' + +class ProductionBootGuardTest < ActiveSupport::TestCase + ROOT = File.expand_path('../..', __dir__) + SECRET_KEYS = %w[DF_SECRET_KEY_BASE DF_SECRET_KEY_ATTR DF_SECRET_KEY_DEVISE].freeze + BOOT_SCRIPT = <<~RUBY + require 'rails/all' + require 'dotenv' + require 'dotenv/rails' + + # Exercise the real production application configuration with empty + # encrypted credentials and no developer .env files. Only the synthetic + # environment below supplies credentials; never read local secrets. + Dotenv::Rails.files.clear + class << Rails::Application + def credentials + @boot_guard_test_credentials ||= ActiveSupport::OrderedOptions.new + end + end + + begin + require './config/application' + puts 'Production configuration accepted' + rescue RuntimeError => error + warn error.message + exit 1 + end + RUBY + + def test_missing_attribute_key_reports_only_that_key_as_missing + assert_missing_keys('DF_SECRET_KEY_ATTR') + end + + def test_missing_devise_key_reports_only_that_key_as_missing + assert_missing_keys('DF_SECRET_KEY_DEVISE') + end + + def test_missing_base_key_reports_only_that_key_as_missing + assert_missing_keys('DF_SECRET_KEY_BASE') + end + + def test_all_missing_keys_are_reported_as_missing + assert_missing_keys(*SECRET_KEYS) + end + + def test_all_present_keys_pass_the_production_guard + output, error, status = boot_configuration + + assert_predicate status, :success?, error + assert_includes output, 'Production configuration accepted' + end + + private + + def assert_missing_keys(*missing_keys) + _output, error, status = boot_configuration(*missing_keys) + + assert_not_predicate status, :success? + assert_includes error, 'Required keys are not set' + SECRET_KEYS.each do |key| + assert_match(/#{key}\s+=> #{!missing_keys.include?(key)}\b/, error) + assert_not_includes error, "boot-guard-fixture-#{key}" + end + end + + def boot_configuration(*missing_keys) + environment = ENV.keys.grep(/\ADF_|\ARAILS_MASTER_KEY\z/).index_with(nil) + environment.merge!('RAILS_ENV' => 'production', 'DF_AUTH_METHOD' => 'database', + 'OVERSEER_ENABLED' => 'false') + SECRET_KEYS.each do |key| + environment[key] = missing_keys.include?(key) ? nil : "boot-guard-fixture-#{key}" + end + + Open3.capture3(environment, RbConfig.ruby, '-e', BOOT_SCRIPT, chdir: ROOT) + end +end diff --git a/test/lib/test_helper_test.rb b/test/lib/test_helper_test.rb new file mode 100644 index 0000000000..5599189e7d --- /dev/null +++ b/test/lib/test_helper_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Exercises the shared setup guard/assignment in test/test_helper.rb by evaluating the +# literal source lines under simulated empty-table conditions (Role/Unit stubbed to zero, +# or Unit.last/maximum stubbed to nil), without touching real fixture data. This keeps the +# tests tied to the actual lines shipped in test_helper.rb rather than a copy of them. +class TestHelperTest < ActiveSupport::TestCase + def test_last_unit_id_capture_survives_an_empty_units_table + assignment_line = test_helper_source_line('@last_unit_id =') + assert(assignment_line, 'expected test_helper.rb to assign @last_unit_id in its setup block') + + Unit.stub(:last, nil) do + Unit.stub(:maximum, nil) do + assert_equal(0, eval(assignment_line)) # rubocop:disable Security/Eval + end + end + end + + def test_setup_aborts_with_a_readable_message_when_the_database_is_unpopulated + guard_line = test_helper_source_line('abort(') + assert(guard_line, 'expected test_helper.rb to guard against an unpopulated test database') + + Role.stub(:count, 0) do + Unit.stub(:count, 5) do + _stdout, stderr = capture_io do + assert_raises(SystemExit) { eval(guard_line) } # rubocop:disable Security/Eval + end + assert_includes stderr, 'rake test:setup' + end + end + end + + def test_setup_does_not_abort_when_seed_data_is_present + guard_line = test_helper_source_line('abort(') + assert(guard_line, 'expected test_helper.rb to guard against an unpopulated test database') + + Role.stub(:count, 3) do + Unit.stub(:count, 3) do + assert_nil(eval(guard_line)) # rubocop:disable Security/Eval + end + end + end + + private + + def test_helper_source_line(needle) + File.readlines(Rails.root.join('test/test_helper.rb')) + .find { |line| line.strip.start_with?(needle) } + &.strip + end +end diff --git a/test/lib/test_shard_test.rb b/test/lib/test_shard_test.rb new file mode 100644 index 0000000000..8c6123973b --- /dev/null +++ b/test/lib/test_shard_test.rb @@ -0,0 +1,400 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'tmpdir' +require Rails.root.join('script/test_shard').to_s + +class TestShardTest < ActiveSupport::TestCase + def test_build_is_deterministic_balanced_and_assigns_every_runnable_once + Dir.mktmpdir do |test_root| + line_counts = [90, 70, 50, 30, 20, 10] + line_counts.each_with_index do |line_count, index| + path = File.join(test_root, "file_#{index}_test.rb") + File.write(path, "# test line\n" * line_count) + end + + first = TestShard.build(test_root: test_root, shard_count: 3) + second = TestShard.build(test_root: test_root, shard_count: 3) + assigned_runnables = first.flat_map { |shard| shard.fetch(:runnables) } + expected_runnables = TestShard.all_runnables(test_root: test_root) + shard_weights = first.map { |shard| shard.fetch(:weight) } + + assert_equal first, second + assert_equal expected_runnables, assigned_runnables.sort + assert_equal expected_runnables.length, assigned_runnables.uniq.length + assert(first.all? { |shard| shard.fetch(:runnables).any? }) + assert_operator shard_weights.max - shard_weights.min, :<=, line_counts.max / TestShard::DEFAULT_LINES_PER_SECOND + end + end + + def test_split_units_include_each_def_and_dsl_test_method_exactly_once + Dir.mktmpdir do |repository_root| + test_root = File.join(repository_root, 'test') + path = File.join(test_root, 'models', 'task_test.rb') + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~RUBY) + class TaskTest + def test_first + assert true + end + + test 'second test' do + assert true + end + + def helper_method + :not_a_test + end + + def test_third + assert true + end + + test('fourth test') do + assert true + end + end + RUBY + + units = TestShard.split_units(path, 'test/models/task_test.rb', 2) + runnables = units.flat_map { |unit| unit.fetch(:runnables) } + + expected_runnables = %w[ + test/models/task_test.rb:2 + test/models/task_test.rb:6 + test/models/task_test.rb:14 + test/models/task_test.rb:18 + ] + assert_equal expected_runnables.sort, runnables.sort + assert_equal runnables.length, runnables.uniq.length + assert(units.all? { |unit| unit.fetch(:runnables).any? }) + end + end + + def test_split_units_use_selector_runtime_weights + Dir.mktmpdir do |repository_root| + path = File.join(repository_root, 'test', 'models', 'task_test.rb') + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~RUBY) + class TaskTest + def test_slow + assert true + end + + def test_fast_one + assert true + end + + def test_fast_two + assert true + end + end + RUBY + relative_path = 'test/models/task_test.rb' + selectors = TestShard.method_runnables(path, relative_path).map { |method| method.fetch(:runnable) } + runtime_weights = selectors.zip([100.0, 1.0, 1.0]).to_h + + units = TestShard.split_units(path, relative_path, 2, runtime_weights: runtime_weights) + + assert_equal [2.0, 100.0], units.map { |unit| unit.fetch(:weight) }.sort + assert_equal selectors.sort, units.flat_map { |unit| unit.fetch(:runnables) }.sort + end + end + + def test_hosted_runtime_weights_require_the_exact_selector_inventory + Dir.mktmpdir do |test_root| + 4.times do |index| + File.write(File.join(test_root, "file_#{index}_test.rb"), "# test line\n") + end + runnables = TestShard.all_runnables(test_root: test_root) + profile = { + selector_count: runnables.length, + fingerprint: TestShard.runnable_profile_fingerprint(test_root: test_root, runnables: runnables), + weights: [100.0, 3.0, 2.0, 1.0] + } + + assert_equal( + runnables.zip(profile.fetch(:weights)).to_h, + TestShard.hosted_runtime_weights(test_root: test_root, runtime_profile: profile) + ) + File.write(File.join(test_root, 'file_0_test.rb'), "# changed test source\n", mode: 'a') + assert_empty(TestShard.hosted_runtime_weights(test_root: test_root, runtime_profile: profile)) + assert_empty( + TestShard.hosted_runtime_weights( + test_root: test_root, + runtime_profile: profile.merge(fingerprint: '0' * 64, weights: []) + ) + ) + end + end + + def test_hosted_runtime_weights_reject_an_invalid_matching_profile + Dir.mktmpdir do |test_root| + File.write(File.join(test_root, 'file_test.rb'), "# test line\n") + runnables = TestShard.all_runnables(test_root: test_root) + profile = { + selector_count: runnables.length, + fingerprint: TestShard.runnable_profile_fingerprint(test_root: test_root, runnables: runnables), + weights: [0.0] + } + + error = assert_raises(SystemExit) do + TestShard.hosted_runtime_weights(test_root: test_root, runtime_profile: profile) + end + + assert_includes error.message, 'invalid weights' + end + end + + def test_split_units_reject_unsupported_dynamic_test_declarations + Dir.mktmpdir do |repository_root| + path = File.join(repository_root, 'test', 'models', 'task_test.rb') + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~RUBY) + class TaskTest + define_method(:test_dynamic) do + assert true + end + end + RUBY + + _output, error = capture_io do + assert_raises(SystemExit) do + TestShard.method_runnables(path, 'test/models/task_test.rb') + end + end + assert_includes error, 'Unsupported test declaration' + end + end + + def test_write_manifest_creates_an_exact_newline_delimited_file_list + Dir.mktmpdir do |directory| + manifest_path = File.join(directory, 'nested', 'shard-1.txt') + selected_files = %w[test/api/projects_api_test.rb test/models/project_test.rb] + + TestShard.write_manifest(manifest_path, selected_files) + + assert_equal "#{selected_files.join("\n")}\n", File.read(manifest_path) + end + end + + def test_required_services_supports_file_and_file_line_runnables + runnables = [ + 'test/api/users_api_test.rb', + 'test/models/task_test.rb:254', + 'test/models/task_similarity_test.rb' + ] + services = TestShard.required_services(runnables) + + assert_equal({ texlive: true, jplag: true }, services) + assert_equal({ texlive: false, jplag: false }, TestShard.required_services(['test/api/users_api_test.rb'])) + end + + def test_cache_writer_shards_select_first_shard_that_needs_each_service + shards = [ + { runnables: ['test/api/users_api_test.rb'] }, + { runnables: ['test/models/task_test.rb:254'] }, + { runnables: ['test/models/task_similarity_test.rb'] } + ] + + assert_equal({ texlive: 2, jplag: 3 }, TestShard.cache_writer_shards(shards)) + end + + def test_image_build_targets_select_only_required_images_and_cache_writers + shards = [ + { runnables: ['test/api/users_api_test.rb'] }, + { runnables: ['test/models/task_test.rb:254'] }, + { runnables: ['test/models/task_similarity_test.rb'] }, + { runnables: ['test/api/projects_api_test.rb'] }, + { runnables: ['test/models/task_test.rb:300'] } + ] + + assert_equal( + %w[api-cache-writer texlive-cache-writer jplag-cache-writer], + TestShard.image_build_targets( + shards: shards, + logical_shards: [1, 2, 3], + api_cache_writer: true + ) + ) + assert_equal( + %w[api texlive], + TestShard.image_build_targets( + shards: shards, + logical_shards: [4, 5], + api_cache_writer: false + ) + ) + assert_equal( + %w[api texlive jplag], + TestShard.image_build_targets( + shards: shards, + logical_shards: [1, 2, 3], + api_cache_writer: false, + cache_write_enabled: false + ) + ) + end + + def test_image_build_targets_have_one_cache_writer_per_scope_across_all_workers + shards = TestShard.build(test_root: Rails.root.join('test'), shard_count: 20) + workers = TestShard.worker_assignments(shards: shards, worker_count: 5) + worker_targets = workers.each_with_index.map do |worker, index| + TestShard.image_build_targets( + shards: shards, + logical_shards: worker.fetch(:shard_numbers), + api_cache_writer: index.zero? + ) + end + all_targets = worker_targets.flatten + + assert(worker_targets.all? { |targets| targets.one? { |target| target.start_with?('api') } }) + %w[api-cache-writer texlive-cache-writer jplag-cache-writer].each do |writer| + assert_equal 1, all_targets.count(writer), "expected exactly one #{writer}" + end + end + + def test_worker_assignments_balance_and_cover_every_logical_shard_once + shards = [9, 8, 7, 6, 5, 4, 3, 2].map do |weight| + { weight: weight.to_f, runnables: ["test_#{weight}"] } + end + + first = TestShard.worker_assignments(shards: shards, worker_count: 2) + second = TestShard.worker_assignments(shards: shards, worker_count: 2) + assigned = first.flat_map { |worker| worker.fetch(:shard_numbers) } + + assert_equal first, second + assert_equal (1..8).to_a, assigned.sort + assert_equal assigned.length, assigned.uniq.length + assert(first.all? { |worker| worker.fetch(:shard_numbers).length == 4 }) + assert_operator first.map { |worker| worker.fetch(:weight) }.max - + first.map { |worker| worker.fetch(:weight) }.min, :<=, 1.0 + end + + def test_worker_assignments_reject_an_uneven_physical_topology + error = assert_raises(SystemExit) do + TestShard.worker_assignments( + shards: Array.new(6) { { weight: 1.0, runnables: ['test'] } }, + worker_count: 4 + ) + end + + assert_includes error.message, 'divisible' + end + + def test_worker_assignments_use_a_matching_hosted_profile + shards = Array.new(20) { |index| { weight: 1.0, runnables: ["test_#{index + 1}"] } } + runtime_profile = { + shard_count: 20, + worker_count: 5, + fingerprint: TestShard.shard_plan_fingerprint(shards), + weights: [ + 156.687, 118.980, 125.551, 125.041, 107.933, + 76.164, 113.087, 110.238, 175.824, 134.411, + 162.326, 139.937, 67.487, 71.771, 143.119, + 125.460, 113.024, 97.667, 145.716, 120.757 + ] + } + workers = TestShard.worker_assignments(shards: shards, worker_count: 5, runtime_profile: runtime_profile) + + assert_equal( + [ + [4, 8, 9, 13], + [11, 16, 17, 18], + [1, 2, 3, 14], + [6, 10, 19, 20], + [5, 7, 12, 15] + ], + workers.map { |worker| worker.fetch(:shard_numbers) } + ) + end + + def test_worker_assignments_ignore_a_stale_hosted_profile + heavy_shards = [4, 8, 9, 13] + shards = Array.new(20) do |index| + weight = heavy_shards.include?(index + 1) ? 1000.0 : 1.0 + { weight: weight, runnables: ["test_#{index + 1}"] } + end + stale_profile = { + shard_count: 20, + worker_count: 5, + fingerprint: '0' * 64, + weights: Array.new(20, 1.0) + } + workers = TestShard.worker_assignments(shards: shards, worker_count: 5, runtime_profile: stale_profile) + + assert_equal 1, workers.map { |worker| (worker.fetch(:shard_numbers) & heavy_shards).length }.max + end + + def test_write_github_output_appends_boolean_service_flags + Dir.mktmpdir do |directory| + output_path = File.join(directory, 'github-output') + File.write(output_path, "existing=value\n") + + TestShard.write_github_output( + output_path, + ['test/models/task_test.rb:254'], + cache_writer_services: { texlive: true } + ) + + assert_equal <<~OUTPUT, File.read(output_path) + existing=value + needs_texlive=true + writes_texlive_cache=true + needs_jplag=false + writes_jplag_cache=false + OUTPUT + end + end + + def test_execution_runnables_stay_absolute_after_working_directory_changes + Dir.mktmpdir do |repository_root| + Dir.mktmpdir do |other_directory| + runnables = Dir.chdir(other_directory) do + TestShard.execution_runnables( + ['test/api/auth_test.rb', 'test/models/task_test.rb:50'], + repository_root: repository_root + ) + end + + assert_equal [ + File.join(repository_root, 'test/api/auth_test.rb'), + "#{File.join(repository_root, 'test/models/task_test.rb')}:50" + ], runnables + end + end + end + + def test_run_tests_writes_count_and_exact_runnable_identifiers + Dir.mktmpdir do |directory| + run_count_path = File.join(directory, 'shard-1.txt') + executed_runnables_path = File.join(directory, 'shard-1-runnables.txt') + calls = [] + runner = lambda do |runnables| + calls << runnables + [true, 2, %w[FirstTest#test_a SecondTest#test_b]] + end + + TestShard.stub(:run_test_command, runner) do + capture_io do + TestShard.run_tests( + ['test/api/auth_test.rb', 'test/models/task_test.rb:50'], + repository_root: directory, + run_count_path: run_count_path, + executed_runnables_path: executed_runnables_path + ) + end + end + + assert_equal [[ + File.join(directory, 'test/api/auth_test.rb'), + "#{File.join(directory, 'test/models/task_test.rb')}:50" + ]], calls + assert_equal "2\n", File.read(run_count_path) + assert_equal <<~RUNNABLES, File.read(executed_runnables_path) + FirstTest#test_a + SecondTest#test_b + RUNNABLES + end + end +end diff --git a/test/middleware/sentry_tunnel_middleware_test.rb b/test/middleware/sentry_tunnel_middleware_test.rb new file mode 100644 index 0000000000..c1b064d67e --- /dev/null +++ b/test/middleware/sentry_tunnel_middleware_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/object/blank' +require 'minitest/autorun' +require 'stringio' +require 'webmock/minitest' +require_relative '../../app/middleware/sentry_tunnel_middleware' + +class SentryTunnelMiddlewareTest < Minitest::Test + ENVELOPE_URL = 'https://sentry.example/api/123/envelope/?sentry_key=public' + + def setup + @original_dsn = ENV.fetch('SENTRY_DSN', nil) + ENV['SENTRY_DSN'] = 'https://public@sentry.example/123' + @middleware = SentryTunnelMiddleware.new(->(_env) { [404, {}, []] }) + end + + def teardown + @original_dsn.nil? ? ENV.delete('SENTRY_DSN') : ENV['SENTRY_DSN'] = @original_dsn + super + end + + def test_envelope_at_limit_is_forwarded + body = 'a' * SentryTunnelMiddleware::MAX_ENVELOPE_BYTES + request = stub_request(:post, ENVELOPE_URL).with(body: body).to_return(status: 200) + env = request_environment(body, content_length: body.bytesize) + + assert_equal [204, {}, []], @middleware.call(env) + assert_requested request, times: 1 + assert_equal 0, env.fetch('rack.input').pos + end + + def test_envelope_over_limit_without_declared_length_is_rejected + assert_oversized_envelope_rejected(content_length: nil) + end + + def test_envelope_over_limit_with_lying_small_length_is_rejected + assert_oversized_envelope_rejected(content_length: 1) + end + + def test_declared_oversized_envelope_is_rejected_before_reading + request = stub_request(:post, ENVELOPE_URL) + env = request_environment('small', content_length: SentryTunnelMiddleware::MAX_ENVELOPE_BYTES + 1) + + assert_equal [413, { 'content-length' => '0' }, []], @middleware.call(env) + assert_not_requested request + assert_equal 0, env.fetch('rack.input').pos + end + + private + + def assert_oversized_envelope_rejected(content_length:) + body = 'a' * (SentryTunnelMiddleware::MAX_ENVELOPE_BYTES + 1) + request = stub_request(:post, ENVELOPE_URL) + env = request_environment(body, content_length: content_length) + + assert_equal [413, { 'content-length' => '0' }, []], @middleware.call(env) + assert_not_requested request + assert_equal 0, env.fetch('rack.input').pos + end + + def request_environment(body, content_length:) + env = { + 'REQUEST_METHOD' => 'POST', + 'PATH_INFO' => SentryTunnelMiddleware::PATH, + 'CONTENT_TYPE' => 'application/x-sentry-envelope', + 'rack.input' => StringIO.new(body) + } + env['CONTENT_LENGTH'] = content_length.to_s unless content_length.nil? + env + end +end diff --git a/test/models/courseflow_test.rb b/test/models/courseflow_test.rb new file mode 100644 index 0000000000..d05a94390e --- /dev/null +++ b/test/models/courseflow_test.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CourseflowTest < ActiveSupport::TestCase + setup do + @document = JSON.parse(Rails.root.join('docs/courseflow/sample-catalog.json').read) + @course = Courseflow::CatalogImporter.import!(@document) + @user = FactoryBot.create(:user, :student) + end + + def plan_attributes + { + course: @course, user: @user, name: 'Study plan', + periods: [{ 'year' => 2026, 'trimester' => 1 }, { 'year' => 2026, 'trimester' => 2 }, { 'year' => 2026, 'trimester' => 3 }], + slots: [ + { 'unit_code' => 'DEMO101', 'year' => 2026, 'trimester' => 1, 'position' => 1 }, + { 'unit_code' => 'DEMO102', 'year' => 2026, 'trimester' => 2, 'position' => 1 }, + { 'unit_code' => 'DEMO201', 'year' => 2026, 'trimester' => 1, 'position' => 2 } + ] + } + end + + def import_invalid_document + document = @document.deep_dup + document['version'] = 'invalid-test' + yield document + assert_no_difference 'Courseflow::Course.count' do + assert_raises(ArgumentError, ActiveRecord::RecordInvalid) { Courseflow::CatalogImporter.import!(document) } + end + end + + def test_identical_import_is_idempotent_and_changed_version_is_immutable + assert_no_difference 'Courseflow::Course.count' do + assert_equal @course.id, Courseflow::CatalogImporter.import!(@document).id + end + document = @document.merge('name' => 'Changed curriculum') + assert_raises(ArgumentError) { Courseflow::CatalogImporter.import!(document) } + assert_raises(ActiveRecord::RecordInvalid) { @course.update!(units: []) } + assert_equal @document['name'], @course.reload.name + document['version'] = 'QA-2027' + assert_difference 'Courseflow::Course.count', 1 do + Courseflow::CatalogImporter.import!(document) + end + end + + def test_catalog_rejects_unknown_fields_and_untyped_values + import_invalid_document { |document| document['user_id'] = @user.id } + import_invalid_document { |document| document['elective_count'] = '1' } + import_invalid_document { |document| document['name'] = 42 } + import_invalid_document { |document| document['units'] = nil } + import_invalid_document { |document| document['units'] = [] } + import_invalid_document { |document| document['units'][0]['required'] = 'true' } + import_invalid_document { |document| document['units'][0]['offered_trimesters'] = [1.0] } + import_invalid_document { |document| document['units'][0]['offered_trimesters'] = [] } + import_invalid_document { |document| document['units'][0]['prerequisites'] = nil } + end + + def test_catalog_rejects_duplicate_codes_unknown_prerequisites_cycles_and_impossible_counts + import_invalid_document { |document| document['units'][2]['code'] = 'DEMO101' } + import_invalid_document { |document| document['units'][0]['prerequisites'] = ['UNKNOWN'] } + import_invalid_document { |document| document['units'][0]['prerequisites'] = ['DEMO102'] } + import_invalid_document { |document| document['units'][0]['prerequisites'] = ['DEMO101'] } + import_invalid_document { |document| document['elective_count'] = 3 } + import_invalid_document { |document| document['elective_count'] = -1 } + import_invalid_document do |document| + document['units'][0]['prerequisites'] = ['DEMO201'] + document['units'][2]['prerequisites'] = ['DEMO202'] + end + end + + def test_catalog_rejects_excessive_sizes_before_writing + import_invalid_document { |document| document['units'] *= 61 } + import_invalid_document { |document| document['name'] = 'a' * 201 } + import_invalid_document { |document| document['units'][0]['code'] = 'A' * 21 } + Tempfile.create(['courseflow-large', '.json']) do |file| + file.write(' ' * (Courseflow::CatalogImporter::MAX_BYTES + 1)) + file.flush + assert_raises(ArgumentError) { Courseflow::CatalogImporter.import_file!(file.path) } + end + end + + def test_required_prerequisite_chain_must_fit_available_period_limit + import_invalid_document do |document| + document['elective_count'] = 0 + document['units'] = (1..61).map do |index| + { 'code' => "CHAIN#{index}", 'name' => "Chain #{index}", 'required' => true, + 'prerequisites' => index == 1 ? [] : ["CHAIN#{index - 1}"], 'offered_trimesters' => [1, 2, 3] } + end + end + end + + def test_complete_plan_retains_empty_periods_and_returns_no_issues + map = Courseflow::CourseMap.create!(plan_attributes) + assert_equal plan_attributes[:periods], map.reload.periods + assert_empty map.issues + assert map.as_plan['complete'] + assert_equal 0, map.lock_version + end + + def test_incomplete_plans_are_saved_and_report_required_elective_and_order_issues + map = Courseflow::CourseMap.create!(plan_attributes.merge(slots: [])) + assert_equal %w[missing_required missing_required elective_count], map.issues.pluck('code') + map.slots = [plan_attributes[:slots][1].merge('trimester' => 1)] + map.save! + assert_equal %w[missing_required unavailable_trimester prerequisite elective_count], map.issues.pluck('code') + assert_equal false, map.as_plan['complete'] + end + + def test_prerequisites_must_be_strictly_earlier_and_surplus_electives_are_issues + map = Courseflow::CourseMap.new(plan_attributes) + map.slots[0]['trimester'] = 2 + map.slots[0]['position'] = 2 + map.slots << { 'unit_code' => 'DEMO202', 'year' => 2026, 'trimester' => 3, 'position' => 1 } + map.save! + assert_equal %w[prerequisite elective_count], map.issues.pluck('code') + map.slots[0]['year'] = 2025 + map.periods << { 'year' => 2025, 'trimester' => 2 } + map.save! + assert_equal ['elective_count'], map.issues.pluck('code') + end + + def test_invalid_slot_update_preserves_the_entire_saved_plan + map = Courseflow::CourseMap.create!(plan_attributes) + before = map.reload.attributes + map.name = 'Should not save' + map.periods = [{ 'year' => 2027, 'trimester' => 1 }] + map.slots = [plan_attributes[:slots][0], plan_attributes[:slots][0]] + assert_raises(ActiveRecord::RecordInvalid) { map.save! } + assert_equal before, map.reload.attributes + end + + def test_stale_model_writes_cannot_overwrite_saved_plan + map = Courseflow::CourseMap.create!(plan_attributes) + stale = Courseflow::CourseMap.find(map.id) + map.update!(name: 'Newest') + assert_raises(ActiveRecord::StaleObjectError) { stale.update!(name: 'Lost update') } + assert_raises(ActiveRecord::StaleObjectError) { stale.destroy! } + assert_equal 'Newest', map.reload.name + end + + def test_catalog_with_saved_maps_cannot_be_removed_or_edited + map = Courseflow::CourseMap.create!(plan_attributes) + assert_raises(ActiveRecord::DeleteRestrictionError) { @course.destroy! } + assert_raises(ActiveRecord::RecordInvalid) { @course.update!(elective_count: 2) } + assert_equal @course.id, map.reload.course_id + end +end diff --git a/test/models/overseer_image_test.rb b/test/models/overseer_image_test.rb index 50760b2426..4476295d59 100644 --- a/test/models/overseer_image_test.rb +++ b/test/models/overseer_image_test.rb @@ -57,4 +57,31 @@ def test_cannot_inject_code_in_tag oi.tag = 'image$ls' refute oi.valid? end + + def test_database_population_can_create_the_seed_image_without_pulling_it + original_skip = ENV.fetch('SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE', nil) + pull_called = false + created_attributes = nil + image = Object.new + image.define_singleton_method(:tag) { 'bash:latest' } + image.define_singleton_method(:pull_from_docker) { pull_called = true } + create_image = lambda do |**attributes| + created_attributes = attributes + image + end + + OverseerImage.stub(:create!, create_image) do + ENV['SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE'] = 'true' + DatabasePopulator.allocate.generate_overseer_images + end + + assert_equal({ name: 'Bash', tag: 'bash:latest' }, created_attributes) + assert_equal false, pull_called + ensure + if original_skip.nil? + ENV.delete('SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE') + else + ENV['SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE'] = original_skip + end + end end diff --git a/test/security/authentication_callback_security_test.rb b/test/security/authentication_callback_security_test.rb new file mode 100644 index 0000000000..ba6f1b3309 --- /dev/null +++ b/test/security/authentication_callback_security_test.rb @@ -0,0 +1,45 @@ +require 'test_helper' +require 'uri' + +class AuthenticationCallbackSecurityTest < ActiveSupport::TestCase + test 'one-time credentials are encoded in a fragment rather than a query' do + url = AuthenticationHelpers.frontend_sign_in_url( + host: 'https://ontrack.example.edu/', + auth_token: 'token+with/?reserved=characters', + username: 'student+alias@example.edu' + ) + parsed = URI.parse(url) + callback = URI.decode_www_form(parsed.fragment).to_h + + assert_equal 'https', parsed.scheme + assert_equal 'ontrack.example.edu', parsed.host + assert_equal '/sign_in', parsed.path + assert_nil parsed.query + assert_equal 'token+with/?reserved=characters', callback.fetch('authToken') + assert_equal 'student+alias@example.edu', callback.fetch('username') + end + + test 'sensitive callback and request parameters are filtered' do + filtered = Rails.application.config.filter_parameters.map(&:to_s) + + %w[ + authToken + auth_token + ltiToken + lti_token + ltik + password + refresh_token + SAMLResponse + ].each do |parameter| + assert_includes filtered, parameter + end + end + + test 'authentication helper source does not interpolate presented tokens into logs' do + source = File.read(Rails.root.join('app/helpers/authentication_helpers.rb')) + + literal_interpolation = ['#', '{auth_param}'].join + assert_equal false, source.include?(literal_interpolation) + end +end diff --git a/test/services/readiness_check_unit_test.rb b/test/services/readiness_check_unit_test.rb new file mode 100644 index 0000000000..511d3921cf --- /dev/null +++ b/test/services/readiness_check_unit_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require_relative '../../app/services/readiness_check' + +class ReadinessCheckUnitTest < Minitest::Test + class DatabaseConnection + attr_reader :queries + + def initialize(result: 1, error: nil) + @result = result + @error = error + @queries = [] + end + + def select_value(query) + @queries << query + raise @error if @error + + @result + end + end + + class DatabaseConnectionPool + def initialize(connection) + @connection = connection + end + + def with_connection + yield @connection + end + end + + class RedisConnection + def initialize(result: 'PONG', error: nil) + @result = result + @error = error + end + + def ping + raise @error if @error + + @result + end + end + + class RedisGateway + def initialize(connection) + @connection = connection + end + + def redis + yield @connection + end + end + + def test_ready_when_database_and_redis_respond + connection = DatabaseConnection.new + check = build_check(database: connection) + + assert check.ready? + assert_equal ['SELECT 1'], connection.queries + end + + def test_not_ready_when_database_returns_an_unexpected_result + assert_equal false, build_check(database: DatabaseConnection.new(result: 0)).ready? + end + + def test_not_ready_when_database_raises + database = DatabaseConnection.new(error: RuntimeError.new('database details')) + + assert_equal false, build_check(database: database).ready? + end + + def test_not_ready_when_redis_returns_an_unexpected_result + redis = RedisConnection.new(result: 'NOT PONG') + + assert_equal false, build_check(redis: redis).ready? + end + + def test_not_ready_when_redis_raises + redis = RedisConnection.new(error: RuntimeError.new('redis details')) + + assert_equal false, build_check(redis: redis).ready? + end + + private + + def build_check(database: DatabaseConnection.new, redis: RedisConnection.new) + ReadinessCheck.new( + database_connection_pool: DatabaseConnectionPool.new(database), + redis: RedisGateway.new(redis) + ) + end +end diff --git a/test/shell/production_runtime_test.rb b/test/shell/production_runtime_test.rb new file mode 100644 index 0000000000..94eeb3a07a --- /dev/null +++ b/test/shell/production_runtime_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'open3' +require 'tmpdir' + +class ProductionRuntimeTest < Minitest::Test + REPOSITORY_ROOT = File.expand_path('../..', __dir__) + ENVIRONMENT_WRITER = File.join( + REPOSITORY_ROOT, + 'lib/shell/write_cron_environment.sh' + ) + PDFGEN_ENTRY_POINT = File.join( + REPOSITORY_ROOT, + 'lib/shell/pdfgen_entry_point.sh' + ) + SIDEKIQ_ENTRY_POINT = File.join( + REPOSITORY_ROOT, + 'lib/shell/sidekiq_entry_point.sh' + ) + + def test_cron_environment_is_private_filtered_and_shell_safe + Dir.mktmpdir do |directory| + environment_file = File.join(directory, 'container.env') + marker_file = File.join(directory, 'must-not-exist') + secret_value = "line one\nline two ' \" $(touch #{marker_file})" + File.write(environment_file, 'stale environment') + File.chmod(0o644, environment_file) + environment = { + 'BUNDLE_APP_CONFIG' => '/usr/local/bundle', + 'DF_SECRET_KEY_BASE' => secret_value, + 'DOCKER_AUTH_CONFIG' => 'must-not-be-persisted-docker-auth', + 'DOCKER_HOST' => 'tcp://docker-socket-proxy:2375', + 'DOCKER_TLS_VERIFY' => '1', + 'PATH' => ENV.fetch('PATH'), + 'RAILS_ENV' => 'production', + 'RAILS_MASTER_KEY' => 'rails-master-key', + 'UNRELATED_SECRET' => 'must-not-be-persisted' + } + + stdout, stderr, status = Open3.capture3( + environment, + '/bin/bash', + ENVIRONMENT_WRITER, + environment_file, + unsetenv_others: true + ) + + assert status.success?, stderr + assert_empty stdout + assert_equal 0o600, File.stat(environment_file).mode & 0o777 + + contents = File.read(environment_file) + assert_includes contents, 'DF_SECRET_KEY_BASE' + assert_includes contents, 'DOCKER_HOST' + assert_includes contents, 'DOCKER_TLS_VERIFY' + assert_equal false, contents.include?('DOCKER_AUTH_CONFIG') + assert_equal false, contents.include?('must-not-be-persisted-docker-auth') + assert_equal false, contents.include?('UNRELATED_SECRET') + assert_equal false, contents.include?('must-not-be-persisted') + + restore_command = [ + 'source "$1"', + 'printf "%s\\0%s\\0%s\\0%s" "$DF_SECRET_KEY_BASE" "$RAILS_ENV" ' \ + '"$RAILS_MASTER_KEY" "$BUNDLE_APP_CONFIG"' + ].join('; ') + restored, restore_stderr, restore_status = Open3.capture3( + {}, + '/bin/bash', + '-c', + restore_command, + 'restore-cron-environment', + environment_file, + unsetenv_others: true + ) + + assert restore_status.success?, restore_stderr + expected = [ + secret_value, + 'production', + 'rails-master-key', + '/usr/local/bundle' + ].join("\0") + assert_equal expected, restored + assert_equal false, File.exist?(marker_file), 'sourcing the escaped value executed shell syntax' + end + end + + def test_entry_points_use_exec_and_do_not_print_the_environment_file + pdfgen_entry_point = File.read(PDFGEN_ENTRY_POINT) + sidekiq_entry_point = File.read(SIDEKIQ_ENTRY_POINT) + + assert_match(/^exec cron -f$/, pdfgen_entry_point) + assert_equal false, %r{\bcat\s+/container\.env\b}.match?(pdfgen_entry_point) + assert_equal false, /declare\s+-p/.match?(pdfgen_entry_point) + assert_match(/^exec bundle exec sidekiq$/, sidekiq_entry_point) + end + + def test_runtime_shell_scripts_have_valid_bash_syntax + scripts = [ENVIRONMENT_WRITER, PDFGEN_ENTRY_POINT, SIDEKIQ_ENTRY_POINT] + + scripts.each do |script| + _stdout, stderr, status = Open3.capture3('/bin/bash', '-n', script) + assert status.success?, "#{script}: #{stderr}" + end + end +end diff --git a/test/sidekiq/communication_recipient_scope_test.rb b/test/sidekiq/communication_recipient_scope_test.rb new file mode 100644 index 0000000000..0101d653b1 --- /dev/null +++ b/test/sidekiq/communication_recipient_scope_test.rb @@ -0,0 +1,43 @@ +require 'test_helper' + +class CommunicationRecipientScopeTest < ActiveSupport::TestCase + setup do + @unit = FactoryBot.create(:unit, with_students: false, task_count: 0, + stream_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0) + @convenor = @unit.main_convenor_user + @tutor_role = @unit.employ_staff(FactoryBot.create(:user, :tutor), Role.tutor) + @project = @unit.enrol_student(FactoryBot.create(:user, :student), Campus.first) + @job = ExecuteCommunicationSetJob.new + end + + def test_convenor_only_actions_do_not_include_unit_tutors + action = Struct.new(:email_tutors, :email_convenors).new(false, true) + recipients = @job.send(:staff_recipients_for, @project, @unit, action) + + assert_equal [@convenor.id], recipients.map(&:id) + assert_not_includes recipients, @tutor_role.user + end + + def test_action_log_csv_is_delivered_only_to_convenors + set = @unit.communication_sets.create!(name: 'Recipient privacy', active: true) + rule = set.communication_rules.create!(name: 'Private log', operator: 'and', position: 0) + ActionMailer::Base.deliveries.clear + + result = @job.send(:send_action_log_to_convenors, [@project], @unit, rule, []) + + assert_equal [@convenor.email], result.map { |row| row[:recipient_email] } + assert_equal [[@convenor.email]], ActionMailer::Base.deliveries.map(&:to) + assert_equal 1, ActionMailer::Base.deliveries.first.attachments.length + end + + def test_explicit_tutor_actions_still_include_the_assigned_tutor + tutorial = FactoryBot.create(:tutorial, unit: @unit, campus: @project.campus, unit_role: @tutor_role) + @project.enrol_in(tutorial) + @unit.employ_staff(FactoryBot.create(:user, :tutor), Role.tutor) + action = Struct.new(:email_tutors, :email_convenors).new(true, true) + + recipients = @job.send(:staff_recipients_for, @project, @unit, action) + + assert_equal [@convenor.id, @tutor_role.user_id].sort, recipients.map(&:id).sort + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c3aae2fe2b..12c4737f90 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,5 +1,7 @@ -require 'simplecov' -SimpleCov.start 'rails' +if ENV['COVERAGE'] == 'true' + require 'simplecov' + SimpleCov.start 'rails' +end # Setup RAILS_ENV as test and expand config for test environment ENV["RAILS_ENV"] ||= "test" @@ -25,6 +27,11 @@ exit end +# The suite relies on seed data (roles, units) created by `rake test:setup`; +# a migrated-but-empty database lets every test fail with a confusing error +# instead of explaining what's missing, so check for that up front. +abort('Test database has no seed data. Run `rake test:setup` to populate it.') if Role.count.zero? || Unit.count.zero? + # Setup sidekiq require 'sidekiq/testing' Sidekiq::Testing.fake! @@ -32,13 +39,13 @@ # Require minitest extensions require 'minitest/pride' require 'minitest/around' +require 'minitest/mock' require 'webmock/minitest' # Require all test helpers require_all 'test/helpers' require 'rails/test_help' -require 'database_cleaner/active_record' class ActiveSupport::TestCase ActiveRecord::Migration.check_all_pending! @@ -60,11 +67,7 @@ class ActiveSupport::TestCase # -- they do not yet inherit this setting fixtures :all - # Support rollback of db changes after all tests - DatabaseCleaner.strategy = :transaction - setup do - DatabaseCleaner.start WebMock.reset! Sidekiq::Testing.fake! @@ -74,7 +77,7 @@ class ActiveSupport::TestCase TestHelpers::TiiTestHelper.setup_tii_eula TestHelpers::TiiTestHelper.setup_tii_features_enabled - @last_unit_id = Unit.last.id + @last_unit_id = Unit.maximum(:id).to_i end teardown do @@ -84,7 +87,6 @@ class ActiveSupport::TestCase # Destroy any units there were created so that files are cleaned up Unit.where("id > :last_unit_id", last_unit_id: @last_unit_id).destroy_all - DatabaseCleaner.clean Faker::UniqueGenerator.clear ActionMailer::Base.deliveries.clear end diff --git a/texlive.Dockerfile b/texlive.Dockerfile index 79d502a0c5..af96de208d 100644 --- a/texlive.Dockerfile +++ b/texlive.Dockerfile @@ -1,6 +1,7 @@ -FROM debian:bookworm-slim AS texlive-builder +FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 AS texlive-builder -ARG TL_MIRROR="https://mirror.aarnet.edu.au/pub/CTAN/systems/texlive/tlnet" +ARG TL_MIRROR="https://texlive.info/historic/systems/texlive/2025/tlnet-final" +ARG TL_INSTALLER_SHA512="a307d7d11bcbd1f054ad0b0d476f7f12bc1a40d07445020edef8713b44453831d18a2f1722c3d2b0ea2e4fe6c06183a79d1c4049495113f412a9f5a570a8614d" RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -11,7 +12,8 @@ RUN apt-get update && \ xz-utils && \ rm -rf /var/lib/apt/lists/* && \ mkdir /tmp/texlive && cd /tmp/texlive && \ - wget "$TL_MIRROR/install-tl-unx.tar.gz" && \ + wget --https-only "$TL_MIRROR/install-tl-unx.tar.gz" && \ + echo "$TL_INSTALLER_SHA512 install-tl-unx.tar.gz" | sha512sum --check - && \ tar xzvf ./install-tl-unx.tar.gz && \ ( \ echo "selected_scheme scheme-basic" && \ @@ -30,8 +32,10 @@ RUN apt-get update && \ ENV PATH=$PATH:/opt/texlive/bin/x86_64-linux:/opt/texlive/bin/aarch64-linux -# Install required TeX Live packages for lualatex compilation -RUN tlmgr install \ +# Install required TeX Live packages for lualatex compilation. Keep the frozen +# repository explicit here as well as in install-tl so a local tlmgr setting +# cannot make this second phase mutable. +RUN tlmgr --repository "$TL_MIRROR" install \ catchfile \ csvsimple \ environ \ @@ -53,7 +57,7 @@ RUN tlmgr install \ paralist \ pdfcol \ pdflscape \ - pdfmanagement \ + pdfmanagement-testphase \ pdfpages \ tagpdf \ tcolorbox \ @@ -63,7 +67,7 @@ RUN tlmgr install \ enumitem # Final image -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -80,6 +84,16 @@ ENV PATH=$PATH:/opt/texlive/bin/x86_64-linux:/opt/texlive/bin/aarch64-linux # Preload fonts RUN luaotfload-tool --update +# Exercise the same PDF-management ordering used by application.pdf.erbtex in +# the final image. This proves the separately installed implementation and its +# Hyperref integration survived the builder-to-runtime copy. +RUN kpsewhich pdfmanagement-testphase.sty && \ + lualatex --halt-on-error --interaction=nonstopmode \ + --jobname=pdfmanagement-smoke --output-directory=/tmp \ + '\DocumentMetadata{uncompress}\documentclass{article}\usepackage[colorlinks]{hyperref}\begin{document}OnTrack smoke. \href{https://example.invalid}{link}\end{document}' && \ + test -s /tmp/pdfmanagement-smoke.pdf && \ + rm -f /tmp/pdfmanagement-smoke.* + # Copy in Latex build script, along with asset images COPY ./lib/shell/latex_build.sh /texlive/shell/latex_build.sh COPY ./public/assets/images /doubtfire/public/assets/images From 4b51647cc49c9a50b27ff9fc3c4cff09a24f7928 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Mon, 28 Sep 2026 00:29:20 +1000 Subject: [PATCH 2/2] feat(platform): bring in org api PR 178 for the platform files Brings ontrack-features-t2-2026/doubtfire-api#178 to the files this PR already carries, so every file stays in exactly one PR. It carries the schema for the Unit Hub notification and digest frequency columns. --- db/schema.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index bd9e7d60b0..b61fe97b56 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_22_010000) do +ActiveRecord::Schema[8.0].define(version: 2026_09_27_000002) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -1157,6 +1157,11 @@ t.boolean "display_peer_progress", default: true, null: false t.string "theme_preference" t.datetime "theme_preference_updated_at" + t.boolean "receive_unit_hub_notifications", default: true, null: false + t.boolean "receive_unit_hub_email_notifications", default: false, null: false + t.boolean "receive_unit_hub_push_notifications", default: false, null: false + t.boolean "receive_unit_hub_session_reminders", default: false, null: false + t.string "digest_frequency", default: "weekly", null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["login_id"], name: "index_users_on_login_id", unique: true t.index ["role_id"], name: "index_users_on_role_id"