diff --git a/.ci-setup/crontab b/.ci-setup/crontab index b1298d5e39..0030ad8035 100644 --- a/.ci-setup/crontab +++ b/.ci-setup/crontab @@ -4,7 +4,6 @@ PATH=/tmp/texlive/bin/x86_64-linux:/tmp/texlive/bin/aarch64-linux:/usr/local/bun 10,15,20,25,30,35,40,45,50,55 * * * * /doubtfire/lib/shell/generate_pdfs.sh 0,10,20,30,40,50 * * * * /doubtfire/lib/shell/send_overseer_notifications.sh -0 5 * * * /doubtfire/lib/shell/check_plagiarism.sh 0 8 * * * /doubtfire/lib/shell/portfolio_autogen_check.sh 0 7 * * 1 /doubtfire/lib/shell/send_weekly_emails.sh 0 1 * * * /doubtfire/lib/shell/sync_enrolments.sh 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/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..1cc4d7d71b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# The App key is available only to the default-branch policy job. Keep every +# workflow and the evaluator itself under lead review. +/.github/workflows/ @ontrack-features-t2-2026/ontrack-leads +/.github/review-policy/ @ontrack-features-t2-2026/ontrack-leads +/.github/CODEOWNERS @ontrack-features-t2-2026/ontrack-leads 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/review-policy/README.md b/.github/review-policy/README.md new file mode 100644 index 0000000000..bcf9e76f76 --- /dev/null +++ b/.github/review-policy/README.md @@ -0,0 +1,51 @@ +# OnTrack pull-request review policy + +The required status context `ontrack/review-policy` passes when the current pull +request head has either: + +- one approval from a current `ontrack-leads` member; or +- two approvals from distinct current `ontrack-contributors` members. + +Approvals from the pull-request author, bots, stale commits, dismissed reviews, +or reviewers whose latest actionable review requests changes do not count. + +## Security model + +`ontrack-review-policy-signal.yml` is unprivileged and never checks out pull-request +code. A completed signal wakes `ontrack-review-policy.yml` through `workflow_run`. +The evaluator workflow checks out only this directory from the protected default +branch, then mints a short-lived token for the organization-owned GitHub App. + +The App is installed only on the three OnTrack Doubtfire repositories and has: + +- organization Members: read; +- repository Metadata: read (mandatory); +- repository Pull requests: read; and +- repository Commit statuses: write. + +The App has no contents, workflow, administration, merge, or webhook permission. +Its private key is held in `ONTRACK_REVIEW_APP_PRIVATE_KEY` in the +`ontrack-review-policy` environment, which only permits the protected `11.0.x` +branch. Its numeric App ID is held in `ONTRACK_REVIEW_APP_ID`. + +The evaluator reports on the pull-request head commit. GitHub gates on the test merge +commit whenever that commit carries a status and only falls back to the head when it +carries none, so reporting on the test merge commit would move the merge gate onto a +commit that carries none of this repository's other checks. The head is also stable, +where the test merge commit is recomputed every time the base branch moves. +A five-minute reconciliation covers team membership and base-branch changes that +do not emit a pull-request review event. Unchanged results are not republished, +which avoids GitHub's per-commit status limit. + +## Ruleset integration + +Keep the native one-overall-approval rule, stale-review dismissal, and conversation +resolution. Require `ontrack/review-policy` with the OnTrack Review Policy App as +its expected source. Remove the native `ontrack-leads` required-reviewer entry only +after the App status has been observed and made required; otherwise GitHub combines +the native team rules with AND semantics. + +Changes to any workflow, the evaluator, or CODEOWNERS should continue to require +one `ontrack-leads` approval through a path-specific native reviewer rule. This is +necessary because any default-branch workflow could otherwise reference the App's +environment secret. diff --git a/.github/review-policy/evaluate.mjs b/.github/review-policy/evaluate.mjs new file mode 100644 index 0000000000..5def1495f6 --- /dev/null +++ b/.github/review-policy/evaluate.mjs @@ -0,0 +1,529 @@ +import { createSign } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const API_VERSION = '2022-11-28'; +const POLICY_CONTEXT = 'ontrack/review-policy'; +const APP_BOT_LOGIN = 'ontrack-review-policy-t2-2026[bot]'; +const PAGE_SIZE = 100; +const MAX_PAGES = 50; +const ALLOWED_REPOSITORIES = new Set([ + 'doubtfire-deploy', + 'doubtfire-api', + 'doubtfire-web', +]); + +function normalizeLogin(login) { + return String(login || '').toLowerCase(); +} + +function encodeJson(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +export function createAppJwt(appId, privateKey, nowSeconds = Math.floor(Date.now() / 1000)) { + if (!/^\d+$/.test(String(appId))) { + throw new Error('ONTRACK_REVIEW_APP_ID must be a numeric GitHub App ID.'); + } + if (!String(privateKey).includes('PRIVATE KEY')) { + throw new Error('ONTRACK_REVIEW_APP_PRIVATE_KEY is missing or invalid.'); + } + + const header = encodeJson({ alg: 'RS256', typ: 'JWT' }); + const payload = encodeJson({ + iat: nowSeconds - 60, + exp: nowSeconds + 540, + iss: String(appId), + }); + const unsigned = `${header}.${payload}`; + const signer = createSign('RSA-SHA256'); + signer.update(unsigned); + signer.end(); + const signature = signer.sign(privateKey).toString('base64url'); + return `${unsigned}.${signature}`; +} + +export function approvedReviewers(reviews, headSha, authorLogin) { + const author = normalizeLogin(authorLogin); + const latestActionableReview = new Map(); + const actionableStates = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + + const ordered = [...reviews].sort((left, right) => { + const leftTime = Date.parse(left.submitted_at || 0) || 0; + const rightTime = Date.parse(right.submitted_at || 0) || 0; + return leftTime - rightTime || Number(left.id || 0) - Number(right.id || 0); + }); + + for (const review of ordered) { + const login = normalizeLogin(review.user?.login); + const state = String(review.state || '').toUpperCase(); + if (!login || login === author || review.user?.type === 'Bot') { + continue; + } + // A comment after an approval does not revoke the approval. + if (!actionableStates.has(state)) { + continue; + } + latestActionableReview.set(login, review); + } + + return new Set( + [...latestActionableReview.entries()] + .filter(([, review]) => ( + String(review.state || '').toUpperCase() === 'APPROVED' + && review.commit_id === headSha + )) + .map(([login]) => login), + ); +} + +export function evaluatePolicy(approved, leadMembers, contributorMembers) { + const leads = new Set([...leadMembers].map(normalizeLogin)); + const contributors = new Set([...contributorMembers].map(normalizeLogin)); + let leadApprovals = 0; + let contributorApprovals = 0; + + for (const login of approved) { + const normalized = normalizeLogin(login); + if (leads.has(normalized)) { + leadApprovals += 1; + } + if (contributors.has(normalized)) { + contributorApprovals += 1; + } + } + + return { + leadApprovals, + contributorApprovals, + passes: leadApprovals >= 1 || contributorApprovals >= 2, + }; +} + +export function pullRequestNumbersFromWorkflowRun(workflowRun) { + const numbers = new Set(); + for (const pullRequest of workflowRun?.pull_requests || []) { + const number = Number(pullRequest?.number); + if (Number.isSafeInteger(number) && number > 0) { + numbers.add(number); + } + } + + const title = String(workflowRun?.display_title || ''); + const titleMatch = title.match(/\bPR #([1-9]\d{0,9})\b/); + if (titleMatch) { + const number = Number(titleMatch[1]); + if (Number.isSafeInteger(number)) { + numbers.add(number); + } + } + return [...numbers]; +} + +export function safeError(error) { + return String(error?.message || error || 'Unknown error') + .replace(/gh[opsu]_[A-Za-z0-9.\-_]{36,}/g, '[redacted token]') + .replace( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, + '[redacted private key]', + ) + .slice(0, 500); +} + +function repositoryParts(repository) { + const match = String(repository || '').match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/); + if (!match) { + throw new Error('GITHUB_REPOSITORY is invalid.'); + } + return { owner: match[1], repo: match[2] }; +} + +class GitHubApi { + constructor(apiUrl, token) { + this.apiUrl = String(apiUrl || 'https://api.github.com').replace(/\/$/, ''); + this.token = token; + } + + async request(path, { method = 'GET', body, expected = [200] } = {}) { + const response = await fetch(`${this.apiUrl}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${this.token}`, + 'User-Agent': 'ontrack-review-policy', + 'X-GitHub-Api-Version': API_VERSION, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!expected.includes(response.status)) { + const requestId = response.headers.get('x-github-request-id'); + throw new Error( + `GitHub API ${method} ${path} returned ${response.status}` + + (requestId ? ` (request ${requestId})` : ''), + ); + } + + if (response.status === 204) { + return null; + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async paginate(path) { + const items = []; + const separator = path.includes('?') ? '&' : '?'; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const batch = await this.request( + `${path}${separator}per_page=${PAGE_SIZE}&page=${page}`, + ); + if (!Array.isArray(batch)) { + throw new Error(`Expected a list from GitHub API path ${path}.`); + } + items.push(...batch); + if (batch.length < PAGE_SIZE) { + return items; + } + } + throw new Error(`GitHub API pagination exceeded ${MAX_PAGES} pages for ${path}.`); + } +} + +async function mintInstallationToken({ apiUrl, owner, repo, appId, privateKey }) { + const appJwt = createAppJwt(appId, privateKey); + const appApi = new GitHubApi(apiUrl, appJwt); + const installation = await appApi.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/installation`, + ); + const tokenResponse = await appApi.request( + `/app/installations/${installation.id}/access_tokens`, + { + method: 'POST', + expected: [201], + body: { + repositories: [repo], + permissions: { + members: 'read', + pull_requests: 'read', + statuses: 'write', + }, + }, + }, + ); + + if (!tokenResponse?.token) { + throw new Error('GitHub did not return an installation access token.'); + } + // Generated tokens are not repository secrets, so mask them explicitly. + console.log(`::add-mask::${tokenResponse.token}`); + return tokenResponse.token; +} + +async function teamMembers(api, owner, teamSlug) { + const members = await api.paginate( + `/orgs/${encodeURIComponent(owner)}/teams/${encodeURIComponent(teamSlug)}/members`, + ); + return new Set(members.map((member) => normalizeLogin(member.login))); +} + +async function openPullRequests(api, owner, repo) { + return api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=open`, + ); +} + +async function pullRequest(api, owner, repo, number) { + return api.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`, + ); +} + +async function reviewsForPullRequest(api, owner, repo, number) { + return api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/reviews`, + ); +} + +function runUrl(repository, runId) { + return `https://github.com/${repository}/actions/runs/${runId}`; +} + +async function setPolicyStatus(api, owner, repo, sha, state, description, targetUrl) { + if (!/^[0-9a-f]{40}$/i.test(String(sha || ''))) { + throw new Error('Cannot publish the review policy without a valid commit SHA.'); + } + const clippedDescription = description.slice(0, 140); + try { + const statuses = await api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` + + `/commits/${encodeURIComponent(sha)}/statuses`, + ); + const latest = statuses.find((status) => ( + status.context === POLICY_CONTEXT + && normalizeLogin(status.creator?.login) === APP_BOT_LOGIN + )); + if (latest?.state === state && latest?.description === clippedDescription) { + return false; + } + } catch (error) { + // Deduplication is only an optimization. Always attempt the fail-closed write + // when status history cannot be read but the status endpoint may still work. + console.warn(`::warning::Status deduplication failed: ${safeError(error)}`); + } + + await api.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/statuses/${sha}`, + { + method: 'POST', + expected: [201], + body: { + state, + context: POLICY_CONTEXT, + description: clippedDescription, + target_url: targetUrl, + }, + }, + ); + return true; +} + +export { setPolicyStatus }; + +// Report on the pull-request head. GitHub gates on the test merge commit whenever that +// commit carries a status and only falls back to the head when it carries none, and the +// test merge commit carries none of this repository's checks. Reporting there would move +// the whole merge gate onto a commit that CI never sees. The head is also stable while +// the test merge commit is recomputed every time the base branch moves. +export function statusShaForPullRequest(pullRequestToReport) { + return pullRequestToReport.head?.sha; +} + +function reviewDigest(reviews) { + return reviews + .map((review) => ( + `${review.id}:${review.state}:${review.commit_id}:` + + `${review.submitted_at}:${review.user?.id}:${review.user?.login}` + )) + .sort() + .join('|'); +} + +function samePullRequestVersion(left, right) { + return ( + left.state === right.state + && left.draft === right.draft + && left.head?.sha === right.head?.sha + && left.base?.ref === right.base?.ref + && left.base?.sha === right.base?.sha + && left.mergeable === right.mergeable + ); +} + +async function evaluatePullRequest({ + api, + owner, + repo, + pullRequest: current, + leads, + contributors, + targetUrl, + attempt = 0, +}) { + // Re-fetch before evaluating so a delayed workflow_run never trusts its event's + // old head or merge SHA. + current = await pullRequest(api, owner, repo, current.number); + if (current.state !== 'open') { + return; + } + + const statusSha = statusShaForPullRequest(current); + if (current.draft) { + await setPolicyStatus( + api, + owner, + repo, + statusSha, + 'pending', + 'Waiting for the pull request to be marked ready for review', + targetUrl, + ); + console.log(`PR #${current.number}: draft`); + return; + } + + const firstReviews = await reviewsForPullRequest(api, owner, repo, current.number); + const checked = await pullRequest(api, owner, repo, current.number); + const secondReviews = await reviewsForPullRequest(api, owner, repo, current.number); + const live = await pullRequest(api, owner, repo, current.number); + if (checked.state !== 'open' || live.state !== 'open') { + return; + } + const liveStatusSha = statusShaForPullRequest(live); + if ( + !samePullRequestVersion(current, checked) + || !samePullRequestVersion(checked, live) + || reviewDigest(firstReviews) !== reviewDigest(secondReviews) + || liveStatusSha !== statusSha + ) { + if (attempt >= 1) { + throw new Error('Pull request changed repeatedly during evaluation.'); + } + console.log(`PR #${current.number}: changed during evaluation; retrying once`); + return evaluatePullRequest({ + api, + owner, + repo, + pullRequest: live, + leads, + contributors, + targetUrl, + attempt: attempt + 1, + }); + } + + const approved = approvedReviewers( + secondReviews, + live.head.sha, + live.user?.login, + ); + const result = evaluatePolicy(approved, leads, contributors); + const state = result.passes ? 'success' : 'pending'; + const description = result.passes + ? `Passed: ${result.leadApprovals}/1 lead or ${result.contributorApprovals}/2 contributors` + : `Waiting: ${result.leadApprovals}/1 lead or ${result.contributorApprovals}/2 contributors`; + + await setPolicyStatus(api, owner, repo, liveStatusSha, state, description, targetUrl); + console.log( + `PR #${current.number}: lead=${result.leadApprovals}, ` + + `contributors=${result.contributorApprovals}, status=${state}`, + ); +} + +async function eventPayload() { + const payloadPath = process.env.GITHUB_EVENT_PATH; + if (!payloadPath) { + return {}; + } + return JSON.parse(await readFile(payloadPath, 'utf8')); +} + +async function pullRequestsToEvaluate(api, owner, repo, eventName, payload) { + const open = await openPullRequests(api, owner, repo); + if (eventName === 'workflow_run') { + // Treat workflow_run fields only as untrusted locators. Match them against + // live open PRs fetched with the App token before evaluating anything. + const numbers = new Set(pullRequestNumbersFromWorkflowRun(payload.workflow_run)); + const headSha = payload.workflow_run?.head_sha; + const linked = open.filter((candidate) => ( + numbers.has(candidate.number) + || candidate.head?.sha === headSha + || candidate.merge_commit_sha === headSha + )); + return linked.length > 0 ? linked : open; + } + return open; +} + +export async function main() { + const repository = process.env.GITHUB_REPOSITORY; + const { owner, repo } = repositoryParts(repository); + if (owner !== 'ontrack-features-t2-2026' || !ALLOWED_REPOSITORIES.has(repo)) { + throw new Error('This evaluator only runs for the three approved OnTrack repositories.'); + } + + const appId = process.env.ONTRACK_REVIEW_APP_ID; + const privateKey = process.env.ONTRACK_REVIEW_APP_PRIVATE_KEY; + const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; + const token = await mintInstallationToken({ + apiUrl, + owner, + repo, + appId, + privateKey, + }); + const api = new GitHubApi(apiUrl, token); + const payload = await eventPayload(); + const eventName = process.env.GITHUB_EVENT_NAME || ''; + const pullRequests = await pullRequestsToEvaluate(api, owner, repo, eventName, payload); + + if (pullRequests.length === 0) { + console.log('No open pull requests require review-policy evaluation.'); + return; + } + + const leadTeam = process.env.ONTRACK_LEAD_TEAM || 'ontrack-leads'; + const contributorTeam = process.env.ONTRACK_CONTRIBUTOR_TEAM || 'ontrack-contributors'; + let leads; + let contributors; + try { + [leads, contributors] = await Promise.all([ + teamMembers(api, owner, leadTeam), + teamMembers(api, owner, contributorTeam), + ]); + } catch (error) { + const targetUrl = runUrl(repository, process.env.GITHUB_RUN_ID); + for (const current of pullRequests) { + try { + const sha = statusShaForPullRequest(current); + await setPolicyStatus( + api, + owner, + repo, + sha, + 'error', + 'OnTrack team membership could not be verified', + targetUrl, + ); + } catch (statusError) { + console.error(`::error::${safeError(statusError)}`); + } + } + throw error; + } + + const targetUrl = runUrl(repository, process.env.GITHUB_RUN_ID); + const failures = []; + for (const current of pullRequests) { + try { + await evaluatePullRequest({ + api, + owner, + repo, + pullRequest: current, + leads, + contributors, + targetUrl, + }); + } catch (error) { + failures.push(error); + try { + const sha = statusShaForPullRequest(current); + await setPolicyStatus( + api, + owner, + repo, + sha, + 'error', + 'OnTrack review policy evaluation failed', + targetUrl, + ); + } catch (statusError) { + console.error(`::error::${safeError(statusError)}`); + } + console.error(`::error::PR #${current.number}: ${safeError(error)}`); + } + } + + if (failures.length > 0) { + throw new Error(`${failures.length} pull-request evaluation(s) failed.`); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(`::error::${safeError(error)}`); + process.exitCode = 1; + }); +} diff --git a/.github/review-policy/evaluate.test.mjs b/.github/review-policy/evaluate.test.mjs new file mode 100644 index 0000000000..f85928c683 --- /dev/null +++ b/.github/review-policy/evaluate.test.mjs @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import { test } from 'node:test'; + +import { + approvedReviewers, + createAppJwt, + evaluatePolicy, + pullRequestNumbersFromWorkflowRun, + safeError, + setPolicyStatus, + statusShaForPullRequest, +} from './evaluate.mjs'; + +function review({ + id, + login, + state = 'APPROVED', + commit = 'head', + submitted = `2026-08-24T00:00:${String(id).padStart(2, '0')}Z`, + type = 'User', +}) { + return { + id, + state, + commit_id: commit, + submitted_at: submitted, + user: { login, type }, + }; +} + +test('one lead approval passes', () => { + const result = evaluatePolicy( + new Set(['lead']), + new Set(['lead']), + new Set(['lead', 'contributor']), + ); + assert.equal(result.passes, true); + assert.equal(result.leadApprovals, 1); +}); + +test('two distinct contributor approvals pass', () => { + const result = evaluatePolicy( + new Set(['contributor-a', 'contributor-b']), + new Set(['lead']), + new Set(['lead', 'contributor-a', 'contributor-b']), + ); + assert.equal(result.passes, true); + assert.equal(result.contributorApprovals, 2); +}); + +test('one contributor approval does not pass', () => { + const result = evaluatePolicy( + new Set(['contributor-a']), + new Set(['lead']), + new Set(['lead', 'contributor-a']), + ); + assert.equal(result.passes, false); +}); + +test('duplicate approvals from one reviewer count once', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'Contributor-A' }), + review({ id: 2, login: 'contributor-a' }), + ], 'head', 'author'); + assert.deepEqual([...approved], ['contributor-a']); +}); + +test('a later comment does not revoke an approval', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'contributor-a' }), + review({ id: 2, login: 'contributor-a', state: 'COMMENTED' }), + ], 'head', 'author'); + assert.deepEqual([...approved], ['contributor-a']); +}); + +test('a later changes-requested review revokes an approval', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'contributor-a' }), + review({ id: 2, login: 'contributor-a', state: 'CHANGES_REQUESTED' }), + ], 'head', 'author'); + assert.deepEqual([...approved], []); +}); + +test('stale, author, and bot approvals are ignored', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'stale', commit: 'old-head' }), + review({ id: 2, login: 'author' }), + review({ id: 3, login: 'review-bot[bot]', type: 'Bot' }), + ], 'head', 'author'); + assert.deepEqual([...approved], []); +}); + +test('workflow run PR numbers are deduplicated and validated', () => { + assert.deepEqual( + pullRequestNumbersFromWorkflowRun({ + display_title: 'OnTrack review policy signal for PR #42', + pull_requests: [{ number: 42 }, { number: 17 }, { number: 0 }], + }), + [42, 17], + ); +}); + +test('workflow run ignores unsafe or implausibly large PR numbers', () => { + assert.deepEqual( + pullRequestNumbersFromWorkflowRun({ + display_title: 'OnTrack review policy signal for PR #12345678901', + pull_requests: [{ number: Number.MAX_SAFE_INTEGER + 1 }, { number: -4 }], + }), + [], + ); +}); + +test('GitHub App JWT has a valid RSA signature and bounded lifetime', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + const now = 1_800_000_000; + const jwt = createAppJwt('4699573', privateKeyPem, now); + const [header, payload, signature] = jwt.split('.'); + assert.equal( + verify( + 'RSA-SHA256', + Buffer.from(`${header}.${payload}`), + publicKey, + Buffer.from(signature, 'base64url'), + ), + true, + ); + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + assert.equal(claims.iss, '4699573'); + assert.equal(claims.iat, now - 60); + assert.equal(claims.exp, now + 540); +}); + +test('classic and stateless GitHub App tokens are fully redacted from errors', () => { + const classicToken = `ghs_${'a'.repeat(36)}`; + const statelessToken = `ghs_${'A'.repeat(170)}.${'b'.repeat(170)}.${'C'.repeat(170)}`; + + assert.equal( + safeError(new Error(`classic ${classicToken} token`)), + 'classic [redacted token] token', + ); + assert.equal( + safeError(new Error(`stateless ${statelessToken} token`)), + 'stateless [redacted token] token', + ); +}); + +test('unchanged App status is not republished and spoofed sources are ignored', async () => { + const posts = []; + const api = { + async paginate() { + return [ + { + context: 'ontrack/review-policy', + state: 'success', + description: 'Passed', + creator: { login: 'not-the-policy-app[bot]' }, + }, + { + context: 'ontrack/review-policy', + state: 'pending', + description: 'Waiting', + creator: { login: 'ontrack-review-policy-t2-2026[bot]' }, + }, + ]; + }, + async request(path, options) { + posts.push({ path, options }); + return {}; + }, + }; + + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'a'.repeat(40), + 'pending', + 'Waiting', + 'https://example.test/run', + ), + false, + ); + assert.equal(posts.length, 0); + + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'a'.repeat(40), + 'success', + 'Passed', + 'https://example.test/run', + ), + true, + ); + assert.equal(posts.length, 1); +}); + +test('status-history failure does not suppress a fail-closed write', async () => { + const posts = []; + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(message); + const api = { + async paginate() { + throw new Error('history unavailable'); + }, + async request(path, options) { + posts.push({ path, options }); + return {}; + }, + }; + + try { + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'b'.repeat(40), + 'error', + 'Evaluation failed', + 'https://example.test/run', + ), + true, + ); + assert.equal(posts.length, 1); + assert.equal(posts[0].options.body.state, 'error'); + assert.equal(warnings.length, 1); + } finally { + console.warn = originalWarn; + } +}); + +test('the status is reported on the head, never on the test merge commit', () => { + assert.equal( + statusShaForPullRequest({ head: { sha: 'head' }, merge_commit_sha: 'test-merge' }), + 'head', + ); +}); + +test('a conflicting pull request with no test merge commit still reports on its head', () => { + assert.equal( + statusShaForPullRequest({ head: { sha: 'head' }, merge_commit_sha: null }), + 'head', + ); +}); diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000000..d96f693f41 --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,26 @@ +name: Lint workflows +on: + pull_request: + paths: [".github/workflows/**"] + push: + branches: ["11.0.x"] + paths: [".github/workflows/**"] +permissions: + contents: read +jobs: + actionlint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install and run actionlint + env: + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + ACTIONLINT_VERSION: 1.7.12 + run: | + curl --fail --location --silent --show-error \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + --output actionlint.tar.gz + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum --check + tar -xzf actionlint.tar.gz actionlint + ./actionlint -color 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/notify-teams-pr.yml b/.github/workflows/notify-teams-pr.yml new file mode 100644 index 0000000000..164a85b240 --- /dev/null +++ b/.github/workflows/notify-teams-pr.yml @@ -0,0 +1,260 @@ +# NOTE ON THE BASE BRANCH. GitHub loads a `pull_request_target` workflow from the +# repository DEFAULT branch (11.0.x), not from the pull request's base branch. That +# changed on 2025-12-08. So this file is merged into 11.0.x deliberately, against the +# usual CONTRIBUTING rule, and a copy on feature/notifications would be dead code. +# It fires for pull requests into every base branch, which is what we want. +name: Notify Teams when a pull request opens + +on: + pull_request_target: + types: + - opened + - reopened + - ready_for_review + # - review_requested # matches "reviewer alerts" most directly, but adds one + # # message per requested reviewer. Decide, do not default. + +# Removes all GITHUB_TOKEN scopes, which this workflow does not need. It does NOT +# restrict secrets.* - the webhook is protected by the guards below, not by this. +permissions: {} + +jobs: + notify-teams: + name: Post pull request notification to Teams + # The job guard makes the file inert if it ever travels to thoth-tech or + # doubtfire-lms. The step below decides who gets a notification: a pull request + # from a branch in this repository always does, and a fork pull request only if + # its author is in one of the two OnTrack teams. + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Check pull request author team membership + id: team-membership + shell: bash + env: + TEAM_MEMBERSHIP_TOKEN: ${{ secrets.TEAM_MEMBERSHIP_ACCESS }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + # A branch in this repository can only be pushed by someone who already + # has write access, so those pull requests need no lookup. That is also + # the only reason Dependabot notifies: dependabot[bot] is in neither + # team and 404s on both. The team check below is for fork pull requests, + # which is where the webhook actually needed protecting. + if [[ "${PR_HEAD_REPO}" == "${REPOSITORY}" ]]; then + echo "eligible=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + if [[ -z "${TEAM_MEMBERSHIP_TOKEN:-}" ]]; then + echo "::error::The TEAM_MEMBERSHIP_ACCESS secret is not configured." + exit 1 + fi + + # The login goes into a URL path segment, so percent-encode it. curl also + # reads [ ] { } * in a URL as a glob and exits before sending anything, so + # --globoff below. Between them a login like dependabot[bot] is safe. + author_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["PR_AUTHOR"], safe=""))')" + + for team in ontrack-contributors ontrack-leads; do + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --globoff \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${TEAM_MEMBERSHIP_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --output "${RUNNER_TEMP}/team-membership.json" \ + --write-out '%{http_code}' \ + "https://api.github.com/orgs/ontrack-features-t2-2026/teams/${team}/memberships/${author_encoded}" \ + )" || http_status="000" + + case "${http_status}" in + 200) + membership_state="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])' \ + < "${RUNNER_TEMP}/team-membership.json")" + # "pending" is a teammate who has not accepted the org invitation + # yet. They are on the team and they review, so notify them too. + if [[ "${membership_state}" == "active" || "${membership_state}" == "pending" ]]; then + echo "eligible=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + ;; + 404) ;; + *) + echo "::error::GitHub returned HTTP ${http_status} while checking team membership." + exit 1 + ;; + esac + done + + # A skip is a decision, so say so on the run summary. Without this the job + # goes green and looks the same as one that posted a message. + echo "::warning::No Teams notification sent. ${PR_AUTHOR} opened this pull request from a fork and is in neither ontrack-contributors nor ontrack-leads." + echo "eligible=false" >> "${GITHUB_OUTPUT}" + + - name: Build and send Teams notification + if: steps.team-membership.outputs.eligible == 'true' + shell: bash + env: + TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_PR_WEBHOOK_URL }} + PAYLOAD_PATH: ${{ runner.temp }}/teams-pr-notification.json + + REPOSITORY: ${{ github.repository }} + PR_ACTION: ${{ github.event.action }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + PR_HEAD: ${{ github.event.pull_request.head.label }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + + run: | + set -euo pipefail + + if [[ -z "${TEAMS_WEBHOOK_URL:-}" ]]; then + echo "::error::The TEAMS_PR_WEBHOOK_URL Actions secret is not configured." + exit 1 + fi + + python3 - <<'PY' + import json + import os + import re + + + def plain(value): + """Teams renders these fields as Markdown, and titles and branch names + come from anyone who can open a pull request. Removing [ ] < > and + backticks stops a title forging a Markdown link or code span. + Parentheses are left alone so `feat(scope): ...` still reads + properly. Note this does NOT stop Teams auto-linking a bare URL in + a title, it only stops the link TEXT being controlled.""" + value = " ".join(value.split()) + return re.sub(r"[\[\]<>`]", " ", value)[:200] + + + headline = { + "opened": "New pull request opened", + "reopened": "Pull request reopened", + "ready_for_review": "Pull request is ready for review", + }.get(os.environ["PR_ACTION"], "Pull request updated") + + status = ( + "Draft" + if os.environ.get("PR_DRAFT", "").lower() == "true" + else "Ready for review" + ) + + card = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "wrap": True, + "text": headline, + }, + { + "type": "TextBlock", + "wrap": True, + "text": f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Repository", + "value": plain(os.environ["REPOSITORY"]), + }, + { + "title": "Author", + "value": plain(os.environ["PR_AUTHOR"]), + }, + { + "title": "Status", + "value": status, + }, + { + "title": "Branches", + "value": ( + f"{plain(os.environ['PR_HEAD'])} -> " + f"{plain(os.environ['PR_BASE'])}" + ), + }, + ], + }, + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Open pull request", + "url": os.environ["PR_URL"], + } + ], + } + + payload = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": card, + } + ], + } + + with open( + os.environ["PAYLOAD_PATH"], + "w", + encoding="utf-8", + ) as payload_file: + json.dump( + payload, + payload_file, + ensure_ascii=False, + ) + PY + + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Content-Type: application/json' \ + --data-binary "@${PAYLOAD_PATH}" \ + --output "${RUNNER_TEMP}/teams-response.txt" \ + --write-out '%{http_code}' \ + --url "${TEAMS_WEBHOOK_URL}" + )" || http_status="000" + + # Uncomment to debug a failing webhook. The body comes from a third party + # and lands in a PUBLIC Actions log, and GitHub only masks an exact + # full-value match of a secret. Read it once, then comment it out again. + # cat "${RUNNER_TEMP}/teams-response.txt" + + if [[ "${http_status}" != 2* ]]; then + echo "::error::Teams webhook returned HTTP ${http_status}." + exit 1 + fi + + echo "Teams webhook returned HTTP ${http_status} for PR #${PR_NUMBER}." + echo "That means the request was accepted. It does not prove the message rendered in the channel." diff --git a/.github/workflows/ontrack-review-policy-signal.yml b/.github/workflows/ontrack-review-policy-signal.yml new file mode 100644 index 0000000000..d87949cbbc --- /dev/null +++ b/.github/workflows/ontrack-review-policy-signal.yml @@ -0,0 +1,32 @@ +name: OnTrack review policy signal +run-name: "OnTrack review policy signal for PR #${{ github.event.pull_request.number || 'all' }}" + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + - converted_to_draft + pull_request_review: + types: + - submitted + - edited + - dismissed + workflow_dispatch: + +# This workflow deliberately has no permissions and never checks out pull-request +# code or secrets. Its completion wakes the trusted evaluator on the default +# branch; manual runs safely reconcile every open pull request. +permissions: {} + +jobs: + signal: + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-24.04 + timeout-minutes: 1 + steps: + - name: Signal policy evaluation + run: ':' diff --git a/.github/workflows/ontrack-review-policy.yml b/.github/workflows/ontrack-review-policy.yml new file mode 100644 index 0000000000..61c194b103 --- /dev/null +++ b/.github/workflows/ontrack-review-policy.yml @@ -0,0 +1,56 @@ +name: OnTrack review policy evaluator + +on: + workflow_run: + workflows: + - OnTrack review policy signal + types: + - completed + push: + branches: + - 11.0.x + schedule: + # Reconcile team membership and base-branch changes even when no PR event fires. + - cron: '3-58/5 * * * *' + +# The repository token is used only to check out the trusted evaluator from the +# default branch. The GitHub App token is separately scoped to member/PR reads and +# commit-status writes. +permissions: + contents: read + +concurrency: + # Serialize signals for the same PR without allowing unrelated PR activity to + # replace a queued revocation. Scheduled and base-push reconciliations share a + # separate group. Every run re-reads authoritative GitHub state. + group: ontrack-review-policy-${{ github.repository }}-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.display_title || 'reconcile' }} + cancel-in-progress: false + +jobs: + evaluate: + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: + name: ontrack-review-policy + deployment: false + + steps: + - name: Check out the trusted policy evaluator + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: refs/heads/11.0.x + fetch-depth: 1 + persist-credentials: false + sparse-checkout: .github/review-policy + + - name: Verify policy logic + run: node --test .github/review-policy/evaluate.test.mjs + + - name: Evaluate current pull-request approvals + env: + ONTRACK_REVIEW_APP_ID: ${{ vars.ONTRACK_REVIEW_APP_ID }} + ONTRACK_REVIEW_APP_PRIVATE_KEY: ${{ secrets.ONTRACK_REVIEW_APP_PRIVATE_KEY }} + ONTRACK_CONTRIBUTOR_TEAM: ontrack-contributors + ONTRACK_LEAD_TEAM: ontrack-leads + run: node .github/review-policy/evaluate.mjs 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/.github/workflows/weekly-integration-prs.yml b/.github/workflows/weekly-integration-prs.yml new file mode 100644 index 0000000000..badee4e239 --- /dev/null +++ b/.github/workflows/weekly-integration-prs.yml @@ -0,0 +1,98 @@ +name: Weekly integration PRs + +on: + schedule: + - cron: "17 9 * * 1" + timezone: Australia/Melbourne + workflow_dispatch: + +permissions: + contents: read + +jobs: + ensure-pr: + if: github.repository_owner == 'ontrack-features-t2-2026' + name: ${{ matrix.source }} -> ${{ matrix.target }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + strategy: + fail-fast: false + max-parallel: 3 + matrix: + include: + - source: feature/cross-unit + target: 11.0.x + - source: feature/notifications + target: 11.0.x + - source: feature/peer-progress-indicator + target: 11.0.x + + concurrency: + group: ${{ github.workflow }}-${{ matrix.target }}-${{ matrix.source }} + cancel-in-progress: false + + steps: + - name: Ensure pull request exists + shell: bash + env: + GH_TOKEN: ${{ secrets.INTEGRATION_BOT_TOKEN }} + SOURCE_BRANCH: ${{ matrix.source }} + TARGET_BRANCH: ${{ matrix.target }} + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::error::INTEGRATION_BOT_TOKEN is not configured." + exit 1 + fi + + existing="$( + gh api --method GET "repos/$GITHUB_REPOSITORY/pulls" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + -f state=open \ + -f head="${GITHUB_REPOSITORY_OWNER}:${SOURCE_BRANCH}" \ + -f base="$TARGET_BRANCH" \ + -F per_page=1 \ + --jq '.[0].html_url // empty' + )" + + if [[ -n "$existing" ]]; then + echo "Pull request already open: $existing" + exit 0 + fi + + target_ref="$(jq -rn --arg ref "$TARGET_BRANCH" '$ref | @uri')" + source_ref="$(jq -rn --arg ref "$SOURCE_BRANCH" '$ref | @uri')" + + for ref in "$target_ref" "$source_ref"; do + if ! gh api "repos/$GITHUB_REPOSITORY/branches/${ref}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + --silent >/dev/null 2>&1; then + echo "Branch $ref no longer exists in $GITHUB_REPOSITORY; skipping." + exit 0 + fi + done + comparison="$( + gh api "repos/$GITHUB_REPOSITORY/compare/${target_ref}...${source_ref}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + --jq '[.ahead_by, (.files | length)] | @tsv' + )" + read -r ahead changed_files <<< "$comparison" + + if [[ "$ahead" == "0" || "$changed_files" == "0" ]]; then + echo "Nothing to merge from $SOURCE_BRANCH into $TARGET_BRANCH." + exit 0 + fi + + gh api --method POST "repos/$GITHUB_REPOSITORY/pulls" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + -f title="Integration: $SOURCE_BRANCH into $TARGET_BRANCH" \ + -f head="$SOURCE_BRANCH" \ + -f base="$TARGET_BRANCH" \ + -f body="Created by the scheduled integration workflow. The source branch is intentionally retained after merge." \ + --jq '.html_url' diff --git a/.gitignore b/.gitignore index 35a46c3ae7..624d3e54b1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ # 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 +32,7 @@ student-work/ .DS_Store .env .env* +!.env.example /config/credentials/*.yml.enc /config/credentials/*.key /config/master.key 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/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/NOTIFICATIONS.md b/NOTIFICATIONS.md new file mode 100644 index 0000000000..214e195b45 --- /dev/null +++ b/NOTIFICATIONS.md @@ -0,0 +1,123 @@ +# Notifications + +This explains how notifications work in OnTrack after the unification. + +## The idea + +Before, each type of message did its own thing. Emails were sent from many +places. There was no single system. + +Now there is one system. You send a notification once. It goes out on all the +channels the user has turned on: in-app, email, and Web Push when the deployment +has VAPID keys and the user has subscribed a browser. + +## The flow + + something happens in the app + -> you call NotificationService.notify(...) + -> saves an in-app notification (the bell) + -> queues an ID-only email job + -> queues an ID-only push job + -> Sidekiq workers reload the notification and contact providers + +You only call one thing. The system handles the rest. + +## How to send one + +Call this from anywhere in the API code: + + NotificationService.notify( + user: project.student, + type: 'feedback', + event: 'task_comment_created', + message: "New feedback is ready for #{task_definition.name}.", + link: "/#/projects/#{project.id}" + ) + +- user: who gets it. +- type: the category. One of task, feedback, portfolio, extension, general. + This is what the user's on/off setting controls. +- event: the specific thing that happened, as a lower_snake_case string. + Required. Use one event name per ticket, and use the same name every time you + raise that notification, so a notification can always be traced back to the + code that sent it. +- message: the text the user sees. Keep it short, 500 characters at most. +- link: where clicking it should take them. Optional. + +type and event are different on purpose. type is the coarse category the user +switches off in their profile. event is the fine-grained reason, and there will +be many events inside one type. + +## Types and preferences + +Each user already has three on/off settings in their profile: + +- receive_task_notifications +- receive_feedback_notifications +- receive_portfolio_notifications + +The type you pass maps to one of these settings. + +- task uses receive_task_notifications +- feedback uses receive_feedback_notifications +- portfolio uses receive_portfolio_notifications +- extension and general are always sent + +If the matching setting is off, nothing is sent. Not the bell, not the email, +not the push. One switch controls all channels. This keeps it simple. We can add +per-channel switches later if we want. + +## The pieces + +- app/models/notification.rb: the notification record. Has the type, message, + link, and whether it has been read. + +- app/services/notification_service.rb: the one entry point. Checks the setting, + saves the record, and queues the email and push channel jobs. + +- app/sidekiq/notification_email_job.rb: reloads a notification by id and sends + its email on the `mailers` queue. + +- app/sidekiq/push_notification_delivery_job.rb: reloads a notification by id + and hands it to the Web Push delivery channel on the `notifications` queue. + +- app/services/push_notification_service.rb: the Web Push delivery channel. It + remains a safe no-op until both VAPID keys are configured. + +- app/models/push_subscription.rb: stores each user's Web Push subscription details. + +- app/api/push_subscriptions_api.rb: provides the API endpoints for listing, + registering, updating, and removing browser push subscriptions. + +- app/mailers/notifications_mailer.rb: the email. New method single_notification + with templates in app/views/notifications_mailer. + +- app/api/notifications_api.rb: the endpoints the web app calls. + +- app/api/entities/notification_entity.rb: the shape of the data sent back. + +For VAPID key configuration and Web Push setup, see +`docs/notifications/push-setup.md`. + +## The endpoints + + GET /api/notifications list my notifications + GET /api/notifications/unread_count how many I have not read + PUT /api/notifications/:id/read mark one as read + PUT /api/notifications/read_all mark all as read + DELETE /api/notifications/:id delete one + + GET /api/push_subscriptions list my browser subscriptions + POST /api/push_subscriptions register or update a browser + DELETE /api/push_subscriptions remove the browser identified by endpoint + +Every endpoint only ever touches the current user's own notifications. + +## What is on now + +- In-app: working. The record is saved and the endpoints return it. +- Email: working. Best effort. If email fails, the in-app notification is still + saved. +- Push: implemented and deliberately configuration-gated. It sends only when + VAPID keys are set and that user has opted in from a supported browser. Keep + the production keys blank until browser/device acceptance testing is complete. diff --git a/NOTIFICATIONS_STATUS.md b/NOTIFICATIONS_STATUS.md new file mode 100644 index 0000000000..4892e8904d --- /dev/null +++ b/NOTIFICATIONS_STATUS.md @@ -0,0 +1,123 @@ +# Unified Notifications - Status + +> Historical implementation record. The unified in-app, email and Web Push +> paths described as future stages below are now implemented on the 11.0.x branch. +> branch. Use `NOTIFICATIONS.md`, `docs/notifications/push-setup.md`, and the +> review evidence under `docs/notifications/reviews/` for current operation and +> release status. + +Feature: unified notifications (in-app, email, push) for OnTrack. +Base: `11.0.x`. Branch: `feature/notifications` (api and web), off `origin/11.0.x`. +Merge and demo target: `11.0.x`. + +The lead runs all commits, merges, and pushes. This file records what is staged +in the working tree and the exact commands to run. + +## Architecture + +One hub, many channels: + + event happens -> NotificationService.notify(...) -> in-app record + -> email job -> mailer + -> push job -> Web Push (when configured) + +A single category toggle gates every channel. The three existing user +preference columns (`receive_task_notifications`, `receive_feedback_notifications`, +`receive_portfolio_notifications`) map onto the notification `type`. If a +category is off, the notification is suppressed on all channels, including +in-app. Per-channel granularity (a type x channel matrix) is deferred to v2. + +## Stage 1 (done, staged in api working tree) + +New files: +- `app/models/notification.rb` - hub model. Types task/feedback/portfolio/extension/general. `unread` and `recent_first` scopes, `mark_read!`. +- `db/migrate/20260722000001_create_notifications.rb` - notifications table (user_id, notification_type, message, link, read_at, timestamps). Ticket EN-F01 later added `event` and widened `message` to text. +- `app/services/notification_service.rb` - the fan-out entry point. Respects the category preference, creates the in-app record, and queues ID-only email and push jobs. +- `app/services/push_notification_service.rb` - push channel delivery. No-op until VAPID keys exist, so it is safe to run today. +- `app/api/notifications_api.rb` - REST endpoints (list, unread_count, mark read, mark all read, delete). All scoped to `current_user`, so no IDOR. +- `app/api/entities/notification_entity.rb` - response shape. +- `app/views/notifications_mailer/single_notification.{html,text}.erb` - email templates. + +Changed files: +- `app/api/api_root.rb` - mount `NotificationsApi` and add auth, both in a `# Notifications feature` block. +- `app/models/user.rb` - `has_many :notifications` in a `# Notifications feature` block. +- `app/mailers/notifications_mailer.rb` - new `single_notification` method. +- `app/api/users_api.rb` - real bug fix (see below). + +## Bug review result + +- `users_api.rb:69-71` copy-paste bug: REAL, fixed. The three lines all wrote the + portfolio key and read top-level params instead of the nested `:user` hash, so + the nil-default never applied. Replaced with a loop over the three keys on + `params[:user]`. +- "portfolio emails gated by the wrong flag": NOT a bug. `receive_portfolio_notifications` + is enforced at `lib/tasks/generate_pdfs.rake:151`, which gates the + `portfolio_ready` and `portfolio_failed` emails. Line 75 of + `portfolio_evidence.rb` gates a task email (`task_pdf_failed`) by the task flag, + which is correct. No change made here. + +## Endpoints + + GET /api/notifications?unread_only=false + GET /api/notifications/unread_count + PUT /api/notifications/:id/read + PUT /api/notifications/read_all + DELETE /api/notifications/:id + +## How to raise a notification (for teammates wiring events) + + NotificationService.notify( + user: project.student, + type: 'feedback', + event: 'task_comment_created', + message: "New feedback is ready for #{task_definition.name}.", + link: "/#/projects/#{project.id}" + ) + +`event:` is required. It is the specific thing that happened, in +lower_snake_case. See NOTIFICATIONS.md for how it differs from `type:`. + +## Verification (run in the container, host Ruby is 2.6) + +The compose files live in `doubtfire-deploy/development/`. Run all of these from +there. See doubtfire-deploy/RUNNING-LOCALLY.md. + + cd doubtfire-deploy/development + COMPOSE="docker compose -f docker-compose.yml -f docker-compose.local-paths.yml" + +1. Rebuild and start (11.0.x needs Ruby 3.4 and Node 22): + `$COMPOSE up -d --build` +2. Only if migrate fails with a stale-DB error (task_prerequisites doesn't exist), reset first: + `$COMPOSE run --rm --no-deps doubtfire-api bash -c "bundle exec rake db:drop db:create db:schema:load && bundle exec rails db:environment:set RAILS_ENV=development && bundle exec rake db:populate"` +3. Migrate (updates `db/schema.rb`, commit that change after migrating): + `$COMPOSE exec doubtfire-api bundle exec rails db:migrate` +4. Lint the new code: + `$COMPOSE exec doubtfire-api bundle exec rubocop app/models/notification.rb app/services app/api/notifications_api.rb app/api/entities/notification_entity.rb` +5. Smoke test: in a rails console, `NotificationService.notify(user: User.first, type: 'general', event: 'smoke_test', message: 'Hello')`, then `GET /api/notifications` as that user. A mail file should also appear in `doubtfire-deploy/data/tmp/mails/`. + +Still to add for Stage 1 completion (good first tasks, run in container): +- `test/factories/notifications_factory.rb` +- `test/models/notification_test.rb` and `test/api/notifications_api_test.rb` + +## Open decisions for the lead + +1. VAPID keys: where in `doubtfire-deploy` secrets, and who generates them. Suggest + env vars `DOUBTFIRE_VAPID_PUBLIC_KEY` and `DOUBTFIRE_VAPID_PRIVATE_KEY`. Blocks + the Stage 4 push send path. Push code is safe to run before this is set. +2. Notification email sender: set `institution[:email_sender]` in config, or accept + the `noreply@doubtfire.local` fallback for now. +3. `api_root.rb` mount ordering: agree with the cross-unit and peer-progress leads. +4. In-app suppression when a category is off is implemented as decided. Confirm. +5. v1 trigger events: which events create notifications. The mechanism is done; + this is a scoping task for the team. + +## Remaining stages + +- Stage 2 (web): re-home the salvaged #353 header bell to Angular 22 (standalone: false, + routerLink not uiSref, @if/@for), add a notifications API service, register in a + `// Notifications feature` block in `doubtfire-angular.module.ts`. +- Stage 3 (web): settings toggles for the three preference booleans (API already exists). +- Stage 4 (api + web + deploy): Web Push. push_subscriptions table and endpoint, + web-push gem, VAPID keys; SwPush subscribe and permission UI; service worker + push and notificationclick handlers. +- Stage 5: verification per stage. diff --git a/README.md b/README.md index e1207a2a90..b11bdac2c3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ Doubtfire is a feedback-driven learning support system. 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. + ## 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. @@ -35,6 +46,7 @@ Doubtfire requires multiple environment variables that help define settings abou | `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 +61,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,6 +155,13 @@ To run unit tests, execute: $ rake test ``` +Code coverage is disabled during normal test runs to keep feedback fast. To +generate the SimpleCov report explicitly, run: + +```bash +$ COVERAGE=true 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. diff --git a/app/api/api_root.rb b/app/api/api_root.rb index 3dbc682297..0bae8f1b6c 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -66,6 +66,8 @@ class ApiRoot < Grape::API mount GroupSetsApi mount LearningOutcomesApi mount ProjectsApi + mount SettingsPublicApi + mount PeerProgressApi mount SettingsApi mount StudentsApi mount Submission::PortfolioApi @@ -108,9 +110,14 @@ class ApiRoot < Grape::API mount MarkingSessionsApi mount DiscussionPromptsApi mount OverseerStepsApi + mount TaskPrioritizationApi mount Feedback::FeedbackChipApi + # Notifications feature + mount NotificationsApi + mount PushSubscriptionsApi + # # Add auth details to all end points # @@ -125,6 +132,8 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to GroupSetsApi AuthenticationHelpers.add_auth_to LearningOutcomesApi AuthenticationHelpers.add_auth_to ProjectsApi + AuthenticationHelpers.add_auth_to SettingsApi + AuthenticationHelpers.add_auth_to PeerProgressApi AuthenticationHelpers.add_auth_to StudentsApi AuthenticationHelpers.add_auth_to Submission::PortfolioApi AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi @@ -160,8 +169,13 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to MarkingSessionsApi AuthenticationHelpers.add_auth_to DiscussionPromptsApi AuthenticationHelpers.add_auth_to OverseerStepsApi + AuthenticationHelpers.add_auth_to TaskPrioritizationApi AuthenticationHelpers.add_auth_to TutorNotesApi + # Notifications feature + AuthenticationHelpers.add_auth_to NotificationsApi + AuthenticationHelpers.add_auth_to PushSubscriptionsApi + add_swagger_documentation \ base_path: nil, doc_version: 'v11.0.0', diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb index e81de94d1f..6bcf3c024a 100644 --- a/app/api/authentication_api.rb +++ b/app/api/authentication_api.rb @@ -13,6 +13,7 @@ class AuthenticationApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers helpers LtiHelper + helpers FederatedIdentityHelper # # Sign in - only mounted if AAF and SAML auth is NOT used (database auth) @@ -79,7 +80,7 @@ class AuthenticationApi < Grape::API token = user.generate_authentication_token! # Return user details - present :user, user, with: Entities::UserEntity + present :user, user, with: Entities::UserEntity, theme_owner_id: user.id present :auth_token, token.authentication_token present :auth_token_expiry, token.auth_token_expiry set_refresh_cookie_in_response(remember) @@ -108,12 +109,11 @@ class AuthenticationApi < Grape::API logger.info "Authenticate #{user_id_data[:email]} from #{request.ip}" - # Lookup using login_id if it exists - # Lookup using email otherwise and set login_id - # Otherwise create new - user = User.find_by(login_id: user_id_data[:login_id]) || - User.find_by(username: user_id_data[:username]) || - User.find_by(email: user_id_data[:email]) || + # Lookup on what the identity provider asserted, otherwise create new + user = user_for_asserted_identity(login_id: user_id_data[:login_id], + email: user_id_data[:email], + derived_username: user_id_data[:username], + source: request.ip) || User.create do |new_user| # Update new user with details from the SAML response Doubtfire::Application.config.institution_settings.update_user_from_saml_response( @@ -154,7 +154,11 @@ class AuthenticationApi < Grape::API protocol = Rails.env.development? ? 'http' : 'https' host = "#{protocol}://#{host}" end - redirect "#{host}/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + redirect AuthenticationHelpers.frontend_sign_in_url( + host: host, + auth_token: onetime_token.authentication_token, + username: user.username + ) end # Saml 2 logout callback @@ -215,12 +219,11 @@ class AuthenticationApi < Grape::API logger.info "Authenticate #{user_id_data[:email]} from #{request.ip}" - # Lookup using login_id if it exists - # Lookup using email otherwise and set login_id - # Otherwise create new - user = User.find_by(login_id: user_id_data[:login_id]) || - User.find_by(username: user_id_data[:username]) || - User.find_by(email: user_id_data[:email]) || + # Lookup on what the identity provider asserted, otherwise create new + user = user_for_asserted_identity(login_id: user_id_data[:login_id], + email: user_id_data[:email], + derived_username: user_id_data[:username], + source: request.ip) || User.create do |new_user| # Update new user with details from the LTI response Doubtfire::Application.config.institution_settings.update_user_from_lti_response( @@ -294,12 +297,11 @@ class AuthenticationApi < Grape::API logger.info "Authenticate #{email} from #{request.ip}" - # Lookup using login_id if it exists - # Lookup using email otherwise and set login_id - # Otherwise create new - user = User.find_by(login_id: login_id) || - User.find_by(username: email[/(.*)@/, 1]) || - User.find_by(email: email) || + # Lookup on what the identity provider asserted, otherwise create new + user = user_for_asserted_identity(login_id: login_id, + email: email, + derived_username: email[/(.*)@/, 1], + source: request.ip) || User.find_or_create_by(login_id: login_id) do |new_user| role = Role.aaf_affiliation_to_role_id(attrs[:edupersonscopedaffiliation]) first_name = (attrs[:givenname] || attrs[:cn]).capitalize @@ -344,7 +346,11 @@ class AuthenticationApi < Grape::API protocol = Rails.env.development? ? 'http' : 'https' host = "#{protocol}://#{host}" end - redirect "#{host}/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + redirect AuthenticationHelpers.frontend_sign_in_url( + host: host, + auth_token: onetime_token.authentication_token, + username: user.username + ) end end @@ -375,7 +381,7 @@ class AuthenticationApi < Grape::API logger.info "Login #{params[:username]} from #{request.ip}" # Respond user details with new auth token - present :user, user, with: Entities::UserEntity + present :user, user, with: Entities::UserEntity, theme_owner_id: user.id present :auth_token, token.authentication_token present :auth_token_expiry, token.auth_token_expiry set_refresh_cookie_in_response(params[:remember]) @@ -502,7 +508,7 @@ class AuthenticationApi < Grape::API end # Return user details token = current_user.generate_authentication_token!(token_type: :general, force_new: false) - present :user, current_user, with: Entities::UserEntity + present :user, current_user, with: Entities::UserEntity, theme_owner_id: current_user.id present :auth_token, token.authentication_token present :auth_token_expiry, token.auth_token_expiry else diff --git a/app/api/d2l_integration_api/d2l_api.rb b/app/api/d2l_integration_api/d2l_api.rb index 66f7ec635b..00378d91d0 100644 --- a/app/api/d2l_integration_api/d2l_api.rb +++ b/app/api/d2l_integration_api/d2l_api.rb @@ -53,11 +53,11 @@ class D2lApi < Grape::API d2l = unit.d2l_assessment_mapping - if d2l.id != params[:id].to_i + if d2l.nil? || d2l.id != params[:id].to_i error!({ error: 'D2L details not found' }, 404) end - d2l.destroy if d2l.present? + d2l.destroy status 204 end @@ -75,7 +75,7 @@ class D2lApi < Grape::API d2l = unit.d2l_assessment_mapping - if d2l.id != params[:id].to_i + if d2l.nil? || d2l.id != params[:id].to_i error!({ error: 'D2L details not found' }, 404) end diff --git a/app/api/discussion_comment_api.rb b/app/api/discussion_comment_api.rb index ffd70117fc..341a1faaf0 100644 --- a/app/api/discussion_comment_api.rb +++ b/app/api/discussion_comment_api.rb @@ -30,8 +30,8 @@ class DiscussionCommentApi < Grape::API for attached_file in attached_files do if attached_file.present? - error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank? - error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 + error!({ error: 'Attachment is empty.' }, 400) if File.size?(attached_file["tempfile"].path).blank? + error!({ error: 'Attachment exceeds the maximum attachment size of 30MB.' }, 413) unless File.size?(attached_file["tempfile"].path) < 30_000_000 end end @@ -136,8 +136,8 @@ class DiscussionCommentApi < Grape::API attached_file = params[:attachment] if attached_file.present? - error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank? - error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 + error!({ error: 'Attachment is empty.' }, 400) if File.size?(attached_file["tempfile"].path).blank? + error!({ error: 'Attachment exceeds the maximum attachment size of 30MB.' }, 413) unless File.size?(attached_file["tempfile"].path) < 30_000_000 end logger.info("#{current_user.username} - added a reply to the discussion comment #{params[:task_comment_id]} for task #{task.id} (#{task_definition.abbreviation})") diff --git a/app/api/entities/minimal/minimal_unit_entity.rb b/app/api/entities/minimal/minimal_unit_entity.rb index 06f9659f2a..44cc958c92 100644 --- a/app/api/entities/minimal/minimal_unit_entity.rb +++ b/app/api/entities/minimal/minimal_unit_entity.rb @@ -20,6 +20,11 @@ class MinimalUnitEntity < Grape::Entity end expose :active + expose :allow_flexible_dates + expose :ordered_task_definitions, + as: :task_definitions, + using: Entities::TaskDefinitionEntity, + if: :include_task_definitions expose :grade_values expose :grade_definitions end diff --git a/app/api/entities/notification_entity.rb b/app/api/entities/notification_entity.rb new file mode 100644 index 0000000000..a036d4c689 --- /dev/null +++ b/app/api/entities/notification_entity.rb @@ -0,0 +1,10 @@ +module Entities + class NotificationEntity < Grape::Entity + expose :id + expose :notification_type + expose :message + expose :link + expose :read_at + expose :created_at + end +end diff --git a/app/api/entities/project_entity.rb b/app/api/entities/project_entity.rb index ebe150267f..ac4a26c955 100644 --- a/app/api/entities/project_entity.rb +++ b/app/api/entities/project_entity.rb @@ -21,7 +21,7 @@ class ProjectEntity < Grape::Entity expose :task_stats, as: :stats, unless: :for_student - expose :tasks, using: TaskEntity, unless: :summary_only do |project, options| + expose :tasks, using: TaskEntity, if: ->(project, options) { !options[:summary_only] || options[:include_task_definitions] } do |project, options| project.task_details_for_shallow_serializer(options[:user]) end diff --git a/app/api/entities/push_subscription_entity.rb b/app/api/entities/push_subscription_entity.rb new file mode 100644 index 0000000000..32a865022c --- /dev/null +++ b/app/api/entities/push_subscription_entity.rb @@ -0,0 +1,12 @@ +module Entities + class PushSubscriptionEntity < Grape::Entity + expose :id + expose :endpoint + expose :created_at + expose :updated_at + + # p256dh and auth are deliberately not exposed. They are the browser's own + # encryption material, the client already holds them, and nothing in the UI + # needs them read back. + end +end diff --git a/app/api/entities/task_entity.rb b/app/api/entities/task_entity.rb index 1de8fb6b39..746ffb688a 100644 --- a/app/api/entities/task_entity.rb +++ b/app/api/entities/task_entity.rb @@ -32,6 +32,7 @@ class TaskEntity < Grape::Entity expose :similarity_flag, unless: :update_only expose :num_new_comments, unless: :update_only + expose :has_feedback, unless: :update_only # Attributes only included in "update only" diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb index 46f26976be..d23ee49f60 100644 --- a/app/api/entities/unit_entity.rb +++ b/app/api/entities/unit_entity.rb @@ -55,6 +55,11 @@ def can_read_unit_config?(my_role) expose :allow_student_change_tutorial, unless: :summary_only expose :allow_flexible_dates, unless: :summary_only expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only + expose :peer_progress_enabled, + unless: :summary_only, + if: lambda { |_unit, options| + can_read_unit_config?(options[:my_role]) + } expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only diff --git a/app/api/entities/user_entity.rb b/app/api/entities/user_entity.rb index 1a1155e103..6768bc65b2 100644 --- a/app/api/entities/user_entity.rb +++ b/app/api/entities/user_entity.rb @@ -10,8 +10,22 @@ class UserEntity < Grape::Entity expose :receive_task_notifications, unless: :minimal expose :receive_portfolio_notifications, unless: :minimal expose :receive_feedback_notifications, unless: :minimal + expose :display_peer_progress, unless: :minimal expose :opt_in_to_research, unless: :minimal expose :has_run_first_time_setup, unless: :minimal + # Theme preference is account-private presentation state. Only endpoints + # serialising the authenticated account opt in to these fields; shared user + # lookups must not disclose either the choice or when it was made. + expose :theme_preference, + unless: :minimal, + if: lambda { |user, options| + options.key?(:theme_owner_id) && user.id.present? && options[:theme_owner_id] == user.id + } + expose :theme_preference_updated_at, + unless: :minimal, + if: lambda { |user, options| + options.key?(:theme_owner_id) && user.id.present? && options[:theme_owner_id] == user.id + } expose :accepted_tii_eula, unless: :minimal, if: ->(user, options) { TurnItIn.enabled? } do |user, options| if TiiActionFetchFeaturesEnabled.eula_required? diff --git a/app/api/feedback/feedback_chip_api.rb b/app/api/feedback/feedback_chip_api.rb index 6b364ad47a..26f373b0b8 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) @@ -157,8 +157,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 +181,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 +208,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 +231,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/learning_outcomes_api.rb b/app/api/learning_outcomes_api.rb index d7752777e2..25dd832d9b 100644 --- a/app/api/learning_outcomes_api.rb +++ b/app/api/learning_outcomes_api.rb @@ -5,6 +5,7 @@ class LearningOutcomesApi < Grape::API helpers AuthorisationHelpers helpers MimeCheckHelpers helpers CsvHelper + helpers ContextModelHelpers before do authenticated? @@ -26,8 +27,8 @@ class LearningOutcomesApi < Grape::API end post '/:context_type_plural/:context_id/outcomes' do # find context model dynamically - 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, :update error!({ error: 'You are not authorised to create outcomes in this context.' }, 403) @@ -77,8 +78,7 @@ class LearningOutcomesApi < Grape::API end put '/:context_type_plural/:context_id/outcomes/:id' 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]) unless authorise? current_user, context_model, :update error!({ error: 'You are not authorised to update outcomes in this context.' }, 403) @@ -130,8 +130,7 @@ class LearningOutcomesApi < Grape::API end delete '/:context_type_plural/:context_id/outcomes/:id' 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]) unless authorise? current_user, context_model, :update error!({ error: 'You are not authorised to delete outcomes in this context.' }, 403) @@ -169,8 +168,7 @@ class LearningOutcomesApi < Grape::API get '/:context_type_plural/:context_id/outcomes/csv' do # find context model dynamically 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, :update error!({ error: 'You are not authorised to download outcomes for this context.' }, 403) @@ -197,8 +195,7 @@ class LearningOutcomesApi < Grape::API ensure_csv!(params[:file][:tempfile]) # 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]) unless authorise? current_user, context_model, :upload_csv error!({ error: 'Not authorised to upload CSV of outcomes' }, 403) diff --git a/app/api/lti_api.rb b/app/api/lti_api.rb index 7e9bbd4e23..c3a4b6dac9 100644 --- a/app/api/lti_api.rb +++ b/app/api/lti_api.rb @@ -71,30 +71,54 @@ class LtiApi < Grape::API error!({ error: "Missing required fields: #{missing.join(', ')}" }, 400) end - # if current_user.role_id != Role.student_id - # return status 204 - # end + # The token names the person the launch was issued for, and its roles decide + # what that person gets. Apply it to that person, and only while that person + # is the one holding the session. + unless lti_member_is?(member, current_user) + error!({ error: 'This LTI token was not issued for the signed in user.' }, 403) + end - # role = unit.role_for(current_user) - # if !role.nil? && role != Role.student - # # error!({ error: 'Failed to enrol, user is already staff.' }, 400) - # return status 204 - # end + subject = current_user unit_role = Doubtfire::Application.config.institution_settings.should_employ_lti_member(member) - unless unit_role.nil? - unit.employ_staff(current_user, unit_role) - end - - unless Doubtfire::Application.config.institution_settings.should_enrol_lti_member(member) + enrol_member = Doubtfire::Application.config.institution_settings.should_enrol_lti_member(member) + + project = nil + consumed = ConsumedLtiToken.find_by(jti: token['jti']) + + if consumed.present? + # The web client carries the one launch token for the whole session and + # calls this route on every mount of the dashboard, so the subject + # presenting their own token again is not a replay. The token is spent + # either way, so nothing is applied a second time. + unless consumed.spent_by?(subject) + error!({ error: 'This LTI token has already been used.' }, 403) + end + + project = unit.projects.find_by(user_id: subject.id) if enrol_member + else + begin + ActiveRecord::Base.transaction do + # Spend the token before anything is applied. A concurrent replay + # loses on the unique index and rolls the whole enrolment back. + ConsumedLtiToken.consume!(token, user: subject) + + unit.employ_staff(subject, unit_role) unless unit_role.nil? + + # TODO: which campus? + project = unit.enrol_student(subject, nil) if enrol_member + end + rescue ConsumedLtiToken::AlreadyUsed + error!({ error: 'This LTI token has already been used.' }, 403) + end + end + + if project.nil? # error!({ error: 'User can not be enrolled into this unit.' }, 404) - return status 204 + status 204 + else + present project, with: Entities::ProjectEntity, user: subject, for_student: true, in_project: true end - - # TODO: which campus? - project = unit.enrol_student(current_user, nil) - - present project, with: Entities::ProjectEntity, user: current_user, for_student: true, in_project: true end desc 'Enrol a list of students into a linked Lti unit' diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb new file mode 100644 index 0000000000..b40315e768 --- /dev/null +++ b/app/api/notifications_api.rb @@ -0,0 +1,59 @@ +require 'grape' + +class NotificationsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get the current user notifications' + params do + optional :unread_only, type: Boolean, default: false, desc: 'Only return unread notifications' + end + get '/notifications' do + notifications = current_user.notifications.recent_first + notifications = notifications.unread if params[:unread_only] + + present notifications, with: Entities::NotificationEntity + end + + desc 'Get the current user unread notification count' + get '/notifications/unread_count' do + { count: current_user.notifications.unread.count } + end + + desc 'Mark a notification as read' + params do + requires :id, type: Integer, desc: 'The notification id' + end + put '/notifications/:id/read' do + notification = current_user.notifications.find(params[:id]) + notification.mark_read! + + present notification, with: Entities::NotificationEntity + end + + desc 'Mark all of the current user notifications as read' + put '/notifications/read_all' do + # rubocop:disable Rails/SkipsModelValidations + current_user.notifications.unread.update_all(read_at: Time.zone.now) + # rubocop:enable Rails/SkipsModelValidations + + status 200 + { success: true } + end + + desc 'Delete a notification' + params do + requires :id, type: Integer, desc: 'The notification id' + end + delete '/notifications/:id' do + notification = current_user.notifications.find(params[:id]) + notification.destroy! + + status 200 + { success: true } + end +end diff --git a/app/api/overseer_steps_api.rb b/app/api/overseer_steps_api.rb index 018f3cc547..85d4b6661e 100644 --- a/app/api/overseer_steps_api.rb +++ b/app/api/overseer_steps_api.rb @@ -203,7 +203,12 @@ class OverseerStepsApi < Grape::API unit = project.unit - overseer_assessment = OverseerAssessment.find(params[:id]) + # Look the assessment up through the project and task definition in the url, so that + # an id from outside the authorised project raises RecordNotFound and returns a 404. + overseer_assessment = OverseerAssessment.joins(:task) + .where(tasks: { project_id: project.id, task_definition_id: params[:task_def_id] }) + .find(params[:id]) + present overseer_assessment.overseer_step_results, with: Entities::OverseerStepResultEntity, my_role: unit.role_for(current_user) end end diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb new file mode 100644 index 0000000000..c3787e7423 --- /dev/null +++ b/app/api/peer_progress_api.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require 'grape' + +class PeerProgressApi < Grape::API + helpers AuthenticationHelpers + + UNAVAILABLE_MESSAGE = 'Peer progress is currently unavailable.' + NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.' + CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.' + # These two constants are a pair and must not be changed independently. + # + # The zero and hundred edge buckets only hide the underlying submitted count + # while half a bucket is wider than one student's share of the peer-only + # cohort. At 20 remaining peers, one peer is exactly five percentage points + # and zero becomes a singleton bucket. A floor of 21 remaining peers makes + # one peer's share smaller than the boundary, so every returned bucket + # represents at least two possible peer counts. + # + # 21 and 10.0 leave no cohort size at or above the floor from which the count + # can be recovered. peer_progress_api_test.rb asserts the relationship holds. + MINIMUM_SAFE_COHORT_SIZE = 21 + PERCENTAGE_BUCKET_SIZE = + PeerProgressDistributionPolicy::PERCENTAGE_BUCKET_SIZE + + before do + header 'Cache-Control', 'private, no-store' + authenticated? + end + + helpers do + def peer_progress_not_found! + error!({ error: PeerProgressApi::NOT_FOUND_MESSAGE }, 404) + end + + def effective_task(project:, task_definition:) + project.tasks.find_by( + task_definition_id: task_definition.id + ) || Task.new( + project: project, + task_definition: task_definition, + task_status: TaskStatus.not_started, + extensions: 0 + ) + end + + def released_for_project?(project:, task_definition:) + start_date = effective_task( + project: project, + task_definition: task_definition + ).local_start_date + + start_date.present? && start_date <= Time.zone.now + end + + def safe_target_grade(project) + target_grade = project.target_grade + + target_grade if target_grade.present? && + project.unit.grade_value?(target_grade) + end + + def positive_integer_env!(name) + value = Integer(ENV.fetch(name), 10) + raise ArgumentError unless value.positive? + + value + rescue KeyError, ArgumentError + error!({ error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, 503) + end + + def minimum_cohort_size! + value = positive_integer_env!( + 'DF_PPI_MINIMUM_COHORT_SIZE' + ) + + return value if value >= PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + + error!( + { error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, + 503 + ) + end + + def peer_progress_payload(project:, task_definition:, **overrides) + state = { + snapshot: nil, + submitted_percentage: nil, + completed_percentage: nil, + status_distribution: nil, + distribution_unavailable_reason: nil, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + is_user_enabled: current_user.display_peer_progress?, + unavailable_reason: nil, + unavailable_message: '' + } + overrides.assert_valid_keys(*state.keys) + state.merge!(overrides) + + distribution_available = state[:status_distribution].present? + if !distribution_available && + state[:distribution_unavailable_reason].nil? + state[:distribution_unavailable_reason] = state[:unavailable_reason] + end + + { + task_definition_id: task_definition.id, + unit_id: project.unit_id, + target_grade: safe_target_grade(project), + submitted_percentage: state[:submitted_percentage], + completed_percentage: state[:completed_percentage], + status_distribution: state[:status_distribution], + distribution_available: distribution_available, + distribution_unavailable_reason: + state[:distribution_unavailable_reason], + is_suppressed: state[:is_suppressed], + is_stale: state[:is_stale], + is_feature_enabled: state[:is_feature_enabled], + is_user_enabled: state[:is_user_enabled], + last_updated_at: state[:snapshot]&.calculated_at&.utc&.iso8601, + unavailable_reason: state[:unavailable_reason], + unavailable_message: state[:unavailable_message] + } + end + + def peer_progress_result(project:, task_definition:) + unit = project.unit + + unless current_user.display_peer_progress? + return peer_progress_payload( + project: project, + task_definition: task_definition, + is_user_enabled: false, + unavailable_reason: 'user_disabled', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + unless unit.peer_progress_enabled? + return peer_progress_payload( + project: project, + task_definition: task_definition, + is_feature_enabled: false, + unavailable_reason: 'feature_disabled', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + target_grade = safe_target_grade(project) + unless target_grade + return peer_progress_payload( + project: project, + task_definition: task_definition, + unavailable_reason: 'target_grade_unavailable', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + snapshot = unit.peer_progress_snapshots.find_by( + task_definition_id: task_definition.id, + target_grade: target_grade + ) + + if snapshot.nil? || + (project.target_grade_changed_at.present? && + snapshot.calculated_at < project.target_grade_changed_at) + return peer_progress_payload( + project: project, + task_definition: task_definition, + unavailable_reason: 'snapshot_unavailable', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + viewer_task = effective_task( + project: project, + task_definition: task_definition + ) + unless PeerProgressViewerPolicy.viewer_context_current?( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + unavailable_reason: 'snapshot_unavailable', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + minimum_cohort_size = minimum_cohort_size! + stale_after_hours = positive_integer_env!( + 'DF_PPI_STALE_AFTER_HOURS' + ) + + is_stale = snapshot.calculated_at < stale_after_hours.hours.ago + + # Treat an empty cohort exactly like every other cohort below the + # privacy threshold. This prevents the response from revealing + # whether a target-grade group is empty or merely small. + peer_cohort_size = [snapshot.cohort_size - 1, 0].max + if peer_cohort_size < minimum_cohort_size + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_suppressed: true, + is_stale: is_stale, + unavailable_reason: 'insufficient_cohort', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + if peer_progress.nil? + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_stale: is_stale, + unavailable_reason: 'aggregation_incomplete', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + if is_stale + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_stale: true, + unavailable_reason: 'stale', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + **PeerProgressViewerPolicy.public_metrics(peer_progress) + ) + end + end + + desc 'Get anonymous task-level peer progress for the authenticated student', + tags: ['peer_progress'], + summary: 'Get anonymous task-level peer progress' + params do + requires :id, + type: Integer, + desc: 'The authenticated student project ID' + requires :task_definition_id, + type: Integer, + desc: 'The task definition ID' + end + get '/projects/:id/task_def_id/:task_definition_id/peer_progress' do + peer_progress_not_found! if current_user.role.id != Role.student_id + + project = Project.for_user(current_user, false) + .includes(:unit) + .find_by(id: params[:id]) + peer_progress_not_found! if project.nil? + + unit = project.unit + task_definition = unit.task_definitions.find_by( + id: params[:task_definition_id] + ) + peer_progress_not_found! if task_definition.nil? + + peer_progress_not_found! unless released_for_project?( + project: project, + task_definition: task_definition + ) + + target_grade = project.target_grade + if target_grade.present? && unit.grade_value?(target_grade) && + task_definition.target_grade > target_grade + peer_progress_not_found! + end + + present peer_progress_result( + project: project, + task_definition: task_definition + ), with: Grape::Presenters::Presenter + end +end diff --git a/app/api/projects_api.rb b/app/api/projects_api.rb index a895007ff3..4fdb08dbd6 100644 --- a/app/api/projects_api.rb +++ b/app/api/projects_api.rb @@ -1,10 +1,39 @@ require 'grape' class ProjectsApi < Grape::API + TASK_DEFINITION_PRELOADS = [ + :discussion_prompts, + :grade_due_dates, + { learning_outcomes: :linked_outcomes }, + :overseer_steps, + :tutorial_stream + ].freeze + helpers AuthenticationHelpers helpers AuthorisationHelpers helpers DbHelpers + helpers do + def notify_portfolio_received(project) + timezone = project.campus&.timezone.presence || Time.zone.name + received_at = project.portfolio_submission_date.in_time_zone(timezone) + submitted_at = received_at.strftime('%-d %B %Y at %-I:%M %p %Z (UTC%:z)') + product_name = Doubtfire::Application.config.institution[:product_name] + + NotificationService.notify( + user: project.student, + type: 'portfolio', + event: 'portfolio_received', + message: "#{product_name} received your portfolio submission at #{submitted_at}.", + link: "/projects/#{project.id}/dashboard" + ) + rescue StandardError => e + Rails.logger.error( + "Failed to raise portfolio_received notification for project #{project.id}: #{e.message}" + ) + end + end + before do authenticated? end @@ -12,12 +41,17 @@ class ProjectsApi < Grape::API desc "Fetches all of the current user's projects" params do optional :include_inactive, type: Boolean, desc: 'Include projects for units that are no longer active?' + optional :include_task_definitions, type: Boolean, desc: 'Include all task definitions with tasks for each project?' end get '/projects' do include_inactive = params[:include_inactive] || false + include_task_definitions = params[:include_task_definitions] || false projects = Project.eager_load(:unit, :user).for_user current_user, include_inactive - present projects, with: Entities::ProjectEntity, for_student: true, summary_only: true, user: current_user + if include_task_definitions + projects = projects.preload(unit: { task_definitions: TASK_DEFINITION_PRELOADS }) + end + present projects, with: Entities::ProjectEntity, for_student: true, summary_only: true, include_task_definitions: include_task_definitions, user: current_user end desc 'Get project' @@ -147,11 +181,26 @@ class ProjectsApi < Grape::API error!({ error: "You do not have permissions to change this student" }, 403) end - # if someone changes this setting manually, clear the autogenerated status - project.portfolio_auto_generated = false - project.compile_portfolio = params[:compile_portfolio] - project.portfolio_submission_date = Time.zone.now - project.save + new_portfolio_submission = false + submission_saved = false + + # Lock the project while deciding whether this is new so concurrent + # retries cannot both observe the old state and send two receipts. + project.with_lock do + # A true request starts a new manual submission when no manual portfolio + # is already queued. Converting a queued auto-generated portfolio into + # a manual submission is also new; retrying the same request is not. + new_portfolio_submission = params[:compile_portfolio] && + (!project.compile_portfolio? || project.portfolio_auto_generated?) + + # if someone changes this setting manually, clear the autogenerated status + project.portfolio_auto_generated = false + project.compile_portfolio = params[:compile_portfolio] + project.portfolio_submission_date = Time.zone.now if new_portfolio_submission + submission_saved = project.save + end + + notify_portfolio_received(project) if submission_saved && new_portfolio_submission end Entities::ProjectEntity.represent(project, only: [:campus_id, :enrolled, :target_grade, :submitted_grade, :compile_portfolio, :portfolio_available, :uses_draft_learning_summary, :stats], for_student: for_student) diff --git a/app/api/push_subscriptions_api.rb b/app/api/push_subscriptions_api.rb new file mode 100644 index 0000000000..b2c67158cc --- /dev/null +++ b/app/api/push_subscriptions_api.rb @@ -0,0 +1,64 @@ +require 'grape' + +class PushSubscriptionsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get the push subscriptions belonging to the current user' + get '/push_subscriptions' do + present current_user.push_subscriptions.order(:id), with: Entities::PushSubscriptionEntity + end + + # The endpoint posted here is later used as the target of an outbound request + # by PushNotificationService, so it is not accepted as free text. + # PushSubscription validates it against PUSH_SERVICE_HOSTS, and a URL that is + # not an https push service URL fails with a 400 from the handler in + # api_root.rb. PushNotificationService checks again before it sends. + desc 'Register this browser to receive push notifications' + params do + requires :endpoint, type: String, desc: 'The push service URL, from PushSubscription.endpoint' + requires :p256dh, type: String, desc: 'The browser public key, from PushSubscription.getKey("p256dh")' + requires :auth, type: String, desc: 'The browser auth secret, from PushSubscription.getKey("auth")' + end + post '/push_subscriptions' do + subscription = current_user.push_subscriptions.find_by(endpoint: params[:endpoint]) + + # Not ours, or not stored yet. An endpoint identifies a browser rather than + # a person, so an endpoint held by another user means someone has signed in + # on a machine that account used. Move the registration across instead of + # failing on the unique index. + # + # This is the only lookup in this file that is not scoped to current_user, + # and it is safe. The push service delivers to that browser no matter which + # row owns it, and the payload is encrypted to the keys posted here. Taking + # over someone else's endpoint cannot read their notifications, it can only + # stop them arriving, and you need the endpoint URL to try it at all. + subscription ||= PushSubscription.find_by(endpoint: params[:endpoint]) || PushSubscription.new + + subscription.assign_attributes( + user: current_user, + endpoint: params[:endpoint], + p256dh: params[:p256dh], + auth: params[:auth] + ) + subscription.save! + + present subscription, with: Entities::PushSubscriptionEntity + end + + desc 'Stop this browser receiving push notifications' + params do + requires :endpoint, type: String, desc: 'The push service URL to remove' + end + delete '/push_subscriptions' do + subscription = current_user.push_subscriptions.find_by!(endpoint: params[:endpoint]) + subscription.destroy! + + status 200 + { success: true } + end +end diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb index 49968392ca..f5e2a07632 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -1,29 +1,28 @@ require 'grape' class SettingsApi < Grape::API - # - # Returns the current auth method - # - desc 'Return configurable details for the Doubtfire front end' + helpers AuthenticationHelpers + + before do + authenticated? + end + + desc 'Return authenticated feature configuration for the Doubtfire front end' get '/settings' do response = { - externalName: Doubtfire::Application.config.institution[:product_name], - hasLogo: Doubtfire::Application.config.institution[:has_logo], - logoUrl: Doubtfire::Application.config.institution[:logo_url], - logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url], overseerEnabled: Doubtfire::Application.config.overseer_enabled, tiiEnabled: TurnItIn.enabled?, - d2lEnabled: D2lIntegration.enabled? - } + d2lEnabled: D2lIntegration.enabled?, - present response, with: Grape::Presenters::Presenter - end - - desc 'Return privacy policy details' - get '/settings/privacy' do - response = { - privacy: Doubtfire::Application.config.institution[:privacy], - plagiarism: Doubtfire::Application.config.institution[:plagiarism] + # Web push. The VAPID *public* key is not a secret — the browser has to + # send it to the push service to subscribe at all. Serving it here means it + # is configured in one place instead of being copied into the front end and + # going stale the first time the keys are rotated. + # + # Blank when push is not configured, which is how the client knows not to + # offer the opt-in. + pushEnabled: PushNotificationService.configured?, + vapidPublicKey: ENV.fetch('DOUBTFIRE_VAPID_PUBLIC_KEY', nil).presence } present response, with: Grape::Presenters::Presenter diff --git a/app/api/settings_public_api.rb b/app/api/settings_public_api.rb new file mode 100644 index 0000000000..c3a5c34dac --- /dev/null +++ b/app/api/settings_public_api.rb @@ -0,0 +1,27 @@ +require 'grape' + +class SettingsPublicApi < Grape::API + # This endpoint is required before sign-in. + # Keep this response explicitly allowlisted. + desc 'Return public branding details for the Doubtfire front end' + get '/settings/public' do + response = { + externalName: Doubtfire::Application.config.institution[:product_name], + hasLogo: Doubtfire::Application.config.institution[:has_logo], + logoUrl: Doubtfire::Application.config.institution[:logo_url], + logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url] + } + + present response, with: Grape::Presenters::Presenter + end + + desc 'Return public privacy policy details' + get '/settings/privacy' do + response = { + privacy: Doubtfire::Application.config.institution[:privacy], + plagiarism: Doubtfire::Application.config.institution[:plagiarism] + } + + present response, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/submission/portfolio_api.rb b/app/api/submission/portfolio_api.rb index aec64c65e1..6a4983cfc7 100644 --- a/app/api/submission/portfolio_api.rb +++ b/app/api/submission/portfolio_api.rb @@ -34,6 +34,14 @@ class PortfolioApi < Grape::API error!({ error: "'#{file[:filename]}': #{file_result[:msg]}" }, 403) end + max_file_size = Doubtfire::Application.config.max_file_size.to_i + max_file_size = 10_000_000 if max_file_size <= 0 + size_in_mb = max_file_size / 1_000_000 + + if File.size(file[:tempfile].path) > max_file_size + error!({ error: "'#{file[:filename]}' exceeds the #{size_in_mb}MB file limit." }, 413) + end + # Move file into place result = project.move_to_portfolio(file, name, kind) # returns details of file diff --git a/app/api/submission/portfolio_evidence_api.rb b/app/api/submission/portfolio_evidence_api.rb index 8a6d36fe84..8de89612ed 100644 --- a/app/api/submission/portfolio_evidence_api.rb +++ b/app/api/submission/portfolio_evidence_api.rb @@ -53,6 +53,16 @@ def self.logger error!({ error: "This task requires a group submission. Ensure you are in a group for the unit's #{task_definition.group_set.name}" }, 403) end + # A finished task stops accepting new student uploads. Without this the + # upload lands, submission_date and file_uploaded_at are rewritten and the + # assessed pdf is deleted and regenerated, while only the status transition + # is skipped. Staff are still allowed through on purpose, because a tutor + # sometimes has to upload on a student's behalf when a file is corrupt or + # went to the wrong task. + if task.task_submission_closed? && !authorise?(current_user, project, :assess) + error!({ error: 'This task is closed for new submissions.' }, 403) + end + # Check that prerequisite tasks are in the required minimum submitted state prerequisites = task_definition.task_prerequisites prerequisites.each do |prerequisite| diff --git a/app/api/task_comments_api.rb b/app/api/task_comments_api.rb index cfe2500a87..3092c40d76 100644 --- a/app/api/task_comments_api.rb +++ b/app/api/task_comments_api.rb @@ -36,8 +36,8 @@ class TaskCommentsApi < Grape::API end if attached_file.present? - error!({ error: "Attachment is empty." }) if File.size?(attached_file["tempfile"].path).blank? - error!({ error: "Attachment exceeds the maximum attachment size of 30MB." }) unless File.size?(attached_file["tempfile"].path) < 30_000_000 + error!({ error: "Attachment is empty." }, 400) if File.size?(attached_file["tempfile"].path).blank? + error!({ error: "Attachment exceeds the maximum attachment size of 30MB." }, 413) unless File.size?(attached_file["tempfile"].path) < 30_000_000 end type_string = content_type.to_s @@ -48,7 +48,7 @@ class TaskCommentsApi < Grape::API error!(error: 'Original comment is not in this task.') if task.all_comments.find(reply_to_id).blank? end - logger.info("#{current_user.username} - added comment for task #{task.id} (#{task_definition.abbreviation})") + logger.info("user_id=#{current_user.id} added comment for task #{task.id} (#{task_definition.abbreviation})") if attached_file.blank? error!({ error: 'Comment text is empty, unable to add new comment' }, 403) if text_comment.blank? @@ -93,7 +93,10 @@ class TaskCommentsApi < Grape::API if project.has_task_for_task_definition? task_definition task = project.task_for_task_definition(task_definition) - comment = task.comments.find(params[:id]) + # all_comments spans the group's shared submission, so a group member can open + # an attachment posted by another member. It stays bounded by this caller's own + # project via the :get check above, matching the delete and update endpoints. + comment = task.all_comments.find(params[:id]) error!({ error: 'No attachment for this comment.' }, 404) unless %w(audio image pdf).include? comment.content_type @@ -265,7 +268,9 @@ class TaskCommentsApi < Grape::API task = project.task_for_task_definition(task_definition) - task_comment = task.comments.find(params[:id]) + # Group task feedback is shared across every task in the same group + # submission, matching the collection returned by the comments endpoint. + task_comment = task.all_comments.find(params[:id]) task_comment.mark_as_unread(current_user) SessionTracker.record_assessment_activity( diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 5e0be83ee7..8ee18d4cb9 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -108,8 +108,11 @@ class TaskDefinitionsApi < Grape::API end task_def.save! + NewTaskAvailableNotificationJob.track_and_enqueue(task_def) - present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) + present task_def, + with: Entities::TaskDefinitionEntity, + my_role: unit.role_for(current_user) end desc 'Edits the given task definition' @@ -213,6 +216,7 @@ class TaskDefinitionsApi < Grape::API # Bulk update task definition with permitted parameters task_def.update!(task_params) + due_date_change = task_def.saved_change_to_due_date # Set the tutorial stream tutorial_stream_abbr = params[:task_def][:tutorial_stream_abbr] @@ -266,6 +270,25 @@ class TaskDefinitionsApi < Grape::API end end + if due_date_change + previous_due_date, new_due_date = due_date_change.map do |value| + value&.to_date&.iso8601 + end + + begin + TaskDueDateChangedNotificationJob.perform_async( + task_def.id, + previous_due_date, + new_due_date + ) + rescue StandardError => e + Rails.logger.error( + "Failed to enqueue due-date notification for TaskDefinition " \ + "#{task_def.id}: #{e.class} - #{e.message}" + ) + end + end + present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) end diff --git a/app/api/task_prioritization_api.rb b/app/api/task_prioritization_api.rb new file mode 100644 index 0000000000..cd1f8b036c --- /dev/null +++ b/app/api/task_prioritization_api.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'grape' + +class TaskPrioritizationApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers DbHelpers + + DEFAULT_PER_PAGE = 50 + MAX_PER_PAGE = 50 + + before do + authenticated? + end + + desc 'Get prioritized task recommendations for a student', + detail: 'Returns the authenticated student\'s actionable tasks ranked by effective deadline, relative task size, and deadline workload.' + + params do + optional :page, type: Integer, default: 1, values: ->(value) { value.positive? } + optional :per_page, type: Integer, default: DEFAULT_PER_PAGE, values: 1..MAX_PER_PAGE + end + + get '/tasks/recommended' do + recommendations = TaskPrioritizationService.new(current_user).call + offset = (params[:page] - 1) * params[:per_page] + + { + data: recommendations.slice(offset, params[:per_page]) || [], + meta: { + page: params[:page], + per_page: params[:per_page], + total_count: recommendations.length, + total_pages: (recommendations.length / params[:per_page].to_f).ceil + } + } + end +end diff --git a/app/api/tasks_api.rb b/app/api/tasks_api.rb index afd9851fe3..364b2c8c5a 100644 --- a/app/api/tasks_api.rb +++ b/app/api/tasks_api.rb @@ -168,10 +168,34 @@ class TasksApi < Grape::API # check the user can put this task if authorise? current_user, project, :make_submission + # Only staff who can assess this task may write its grade. This is checked + # before anything below writes, so a refused request leaves the task alone. + if !grade.nil? && !authorise?(current_user, project, :assess) + error!({ error: 'You are not permitted to assess this task' }, 403) + end + task = project.task_for_task_definition(task_definition) + # A tutor can both mark and unmark a task as discussed in class. Sending + # discussed:false used to still add a "Discussed in class" comment, the + # opposite of what it asks, and that comment type cannot be removed through + # the UI. So false now removes all discussed markers instead. + # The mark is added here so a same-request complete trigger below can see it; + # a removal is deferred to the end so a later refused trigger or grade does + # not leave the comment destroyed and the request still failing. + remove_discussed = false if !params[:discussed].nil? && authorise?(current_user, project, :assess) - task.add_discussed_comment(current_user) + if params[:discussed] + task.add_discussed_comment(current_user) + elsif task.task_definition.requires_discussion && + (task.task_status == TaskStatus.complete || params[:trigger] == 'complete') + # Removing the mark would leave a discussion-required task complete + # without the evidence the model demands. Refuse before deleting + # anything. + error!({ error: 'Cannot remove the discussed mark from a task that requires discussion while it is complete. Change its status first.' }, 403) + else + remove_discussed = true + end end # if trigger supplied... @@ -211,11 +235,18 @@ class TasksApi < Grape::API recursive_fix: params[:trigger_recursive_fix], check_feedback: true ) - if result.nil? && task.errors.any? - error!({ error: task.errors.full_messages.to_sentence }, 403) - end - if result.nil? && task.task_definition.restrict_status_updates - error!({ error: 'This task can only be updated by your tutor.' }, 403) + # trigger_transition returns nil for every refusal, and most of its early + # returns leave errors empty. Both guards below used to need something + # extra on top of that, so a refused change fell through to the 200 at the + # end of the handler and the client showed it as accepted. + if result.nil? + if task.errors.any? + error!({ error: task.errors.full_messages.to_sentence }, 403) + elsif task.task_definition.restrict_status_updates + error!({ error: 'This task can only be updated by your tutor.' }, 403) + else + error!({ error: 'This status change is not allowed for this task.' }, 403) + end end SessionTracker.record_assessment_activity( action: "assessing", @@ -238,6 +269,10 @@ class TasksApi < Grape::API task.save end + # The status change and grade have been applied without error, so it is now + # safe to remove the discussed mark that was requested with discussed:false. + task.remove_discussed_comment if remove_discussed + present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true else error!({ error: "Couldn't find Task with id=#{params[:id]}" }, 403) @@ -269,7 +304,7 @@ class TasksApi < Grape::API task_definition = project.unit.task_definitions.find(params[:task_definition_id]) # check the user can put this task - error!(error: 'You do not have permission to read submissions for this project.') unless authorise? current_user, project, :get_submission + error!({ error: 'You do not have permission to read submissions for this project.' }, 403) unless authorise? current_user, project, :get_submission # ensure there can be a pdf... needs_upload_docs = !task_definition.upload_requirements.empty? @@ -324,7 +359,7 @@ class TasksApi < Grape::API task_definition = project.unit.task_definitions.find(params[:task_definition_id]) # check the user can put this task - error!(error: 'You do not have permission to read submissions for this project.') unless authorise? current_user, project, :get_submission + error!({ error: 'You do not have permission to read submissions for this project.' }, 403) unless authorise? current_user, project, :get_submission # Get the actual task... task = project.task_for_task_definition(task_definition) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 411065f97e..f8feed3cd0 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -73,6 +73,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 +117,7 @@ class UnitsApi < Grape::API :description, :start_date, :end_date, + :peer_progress_enabled, :teaching_period_id, :active, :main_convenor_id, @@ -659,6 +661,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/api/users_api.rb b/app/api/users_api.rb index 2900bbfba4..4b18b26dda 100644 --- a/app/api/users_api.rb +++ b/app/api/users_api.rb @@ -25,7 +25,9 @@ class UsersApi < Grape::API error!({ error: "Cannot find User with id #{params[:id]}" }, 403) end - present user, with: Entities::UserEntity + present user, + with: Entities::UserEntity, + theme_owner_id: current_user.id end desc 'Get convenors' @@ -59,16 +61,25 @@ class UsersApi < Grape::API optional :receive_task_notifications, type: Boolean, desc: 'Allow user to be sent task notifications' optional :receive_portfolio_notifications, type: Boolean, desc: 'Allow user to be sent portfolio notifications' optional :receive_feedback_notifications, type: Boolean, desc: 'Allow user to be sent feedback notifications' + optional :display_peer_progress, type: Boolean, desc: 'Display anonymous peer progress information' optional :opt_in_to_research, type: Boolean, desc: 'Allow user to opt in to research conducted by Doubtfire' optional :has_run_first_time_setup, type: Boolean, desc: 'Whether or not user has run first-time setup' + optional :theme_preference, type: String, desc: 'Theme preference for the user [light, dark, system]; null means never chosen' end end put '/users/:id' do change_self = (params[:id] == current_user.id) - params[:receive_portfolio_notifications] = true if params.key?(:receive_portfolio_notifications) && params[:receive_portfolio_notifications].nil? - params[:receive_portfolio_notifications] = true if params.key?(:receive_feedback_notifications) && params[:receive_feedback_notifications].nil? - params[:receive_portfolio_notifications] = true if params.key?(:receive_task_notifications) && params[:receive_task_notifications].nil? + # Default notification preferences to true when explicitly sent as null. + # (Previously this wrote the portfolio key three times and read the + # top-level params instead of the nested :user hash, so it never applied.) + %i[receive_task_notifications receive_portfolio_notifications receive_feedback_notifications].each do |pref| + params[:user][pref] = true if params[:user].key?(pref) && params[:user][pref].nil? + end + if params[:user].key?(:display_peer_progress) && + params[:user][:display_peer_progress].nil? + params[:user][:display_peer_progress] = true + end # can only modify if current_user.id is same as :id provided # (i.e., user wants to update their own data) or if update_user token @@ -87,10 +98,17 @@ class UsersApi < Grape::API :receive_task_notifications, :receive_portfolio_notifications, :receive_feedback_notifications, + :display_peer_progress, :opt_in_to_research, - :has_run_first_time_setup + :has_run_first_time_setup, + :theme_preference ) + # Theme preference belongs only to the account itself. Keep authorised + # staff profile updates backward-compatible by ignoring this one private + # field instead of rejecting the rest of an otherwise valid update. + user_parameters.delete(:theme_preference) unless change_self + user.role = Role.student if user.role.nil? old_role = user.role @@ -131,9 +149,18 @@ class UsersApi < Grape::API user_parameters[:role] = new_role end + # An explicit preference is a synchronization write, even when its value + # matches the stored value. Clients use this timestamp to reconcile a + # newer offline choice with the account copy. + if user_parameters.key?(:theme_preference) + user.theme_preference_updated_at = user_parameters[:theme_preference].nil? ? nil : Time.current + end + # Update changes made to user user.update!(user_parameters) - present user, with: Entities::UserEntity + present user, + with: Entities::UserEntity, + theme_owner_id: current_user.id else error!({ error: "Cannot modify user with id=#{params[:id]} - not authorised" }, 403) end diff --git a/app/controllers/readiness_controller.rb b/app/controllers/readiness_controller.rb new file mode 100644 index 0000000000..d23bce182e --- /dev/null +++ b/app/controllers/readiness_controller.rb @@ -0,0 +1,5 @@ +class ReadinessController < ActionController::API + def show + head(ReadinessCheck.new.ready? ? :ok : :service_unavailable) + end +end diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb index 1f81dbe848..ca6c50d538 100644 --- a/app/helpers/authentication_helpers.rb +++ b/app/helpers/authentication_helpers.rb @@ -1,4 +1,5 @@ require 'onelogin/ruby-saml' +require 'uri' # # The AuthenticationHelpers include functions to check if the user @@ -31,7 +32,7 @@ def user_auth_token_type(user_param, auth_param, token_type) if user.present? && token.present? # has the tolken not expired? if token.auth_token_expiry > Time.zone.now - logger.info("Authenticated #{user.username} from #{request.ip}") + logger.info("Authenticated user_id=#{user.id} from #{request.ip}") :valid else # Token is timed out - destroy it and return error @@ -40,7 +41,10 @@ def user_auth_token_type(user_param, auth_param, token_type) :token_expired end elsif token.present? - logger.info("Error logging in for #{user_param} / #{auth_param} from #{request.ip}") + # Never echo the presented credential. This branch is reached for invalid + # and expired tokens, which are exactly the values an attacker may try to + # force into application logs. + logger.info("Error logging in with an invalid one-time token from #{request.ip}") :error else :missing_details @@ -52,6 +56,19 @@ def user_auth_token_type(user_param, auth_param, token_type) # module_function + # Keep one-time sign-in credentials out of the query string. Query strings + # are routinely captured by reverse-proxy access logs, browser history, and + # telemetry. A URI fragment is not sent in the HTTP request; the web client + # consumes and removes it before initialising telemetry. + def frontend_sign_in_url(host:, auth_token:, username:) + callback_fragment = URI.encode_www_form( + authToken: auth_token, + username: username + ) + + "#{host.to_s.delete_suffix('/')}/sign_in##{callback_fragment}" + end + def authenticated_via_refresh_token? auth_param = cookies['refresh_token'] user_param = cookies['username'] @@ -220,7 +237,7 @@ def set_refresh_cookie_in_response(remember) token = current_user.auth_tokens.where(token_type: :refresh_token).last # Generate a new token when the old one is absent or getting close to expiring - if token.nil? || token.auth_token_expiry <= Time.zone.now - 12.hours + if token.nil? || token.auth_token_expiry <= Time.zone.now + 12.hours token = current_user.generate_authentication_token!(token_type: :refresh_token) end diff --git a/app/helpers/authorisation_helpers.rb b/app/helpers/authorisation_helpers.rb index b27fd59024..fe9d7a8727 100644 --- a/app/helpers/authorisation_helpers.rb +++ b/app/helpers/authorisation_helpers.rb @@ -45,16 +45,17 @@ def authorise?(user, object, action, perm_get_fn = method(:get_permission_hash), return false if role_obj.nil? - # 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 + return false if !unit_role.nil? && unit_role.observer_only end role = role_obj.to_sym diff --git a/app/helpers/context_model_helpers.rb b/app/helpers/context_model_helpers.rb new file mode 100644 index 0000000000..4b67e62d8a --- /dev/null +++ b/app/helpers/context_model_helpers.rb @@ -0,0 +1,20 @@ +module ContextModelHelpers + CONTEXT_MODELS = { + 'units' => Unit, + 'task_definitions' => TaskDefinition + }.freeze + + def context_model_for(context_type_plural, context_id) + context_class_for(context_type_plural).find(context_id) + end + + def context_type_for(context_type_plural) + context_class_for(context_type_plural).name + end + + private + + def context_class_for(context_type_plural) + CONTEXT_MODELS.fetch(context_type_plural.to_s) + end +end diff --git a/app/helpers/federated_identity_helper.rb b/app/helpers/federated_identity_helper.rb new file mode 100644 index 0000000000..40aa05d923 --- /dev/null +++ b/app/helpers/federated_identity_helper.rb @@ -0,0 +1,57 @@ +# +# Resolves the user that a federated assertion is about. +# +# The identity provider asserts a login_id and an email, and those are the only +# two things a federated sign in may be matched on. The username is derived from +# the local part of the email, so two people at different domains derive the +# same one and an account found that way is not necessarily the person the +# assertion is about. +# +module FederatedIdentityHelper + include LogHelper + + # + # Find the existing user this assertion is about, or nil so the caller creates + # one. Source is what a near miss gets logged against, the request ip for a + # sign in and the job context for a background import. + # + def user_for_asserted_identity(login_id:, email:, derived_username:, source:) + if login_id.present? + user = User.find_by(login_id: login_id) + return user unless user.nil? + end + + if email.present? + user = User.find_by(email: email) + + # A pre-federation account with no login id can be adopted on its asserted + # email. Once both the assertion and the account carry a login id, though, + # a mismatch settles the question: falling through to email would hand a + # shared or reused address to the wrong federated identity. + return user if user.present? && (login_id.blank? || user.login_id.blank?) + + unless user.nil? + logger.info "Refused email fallback for #{login_id} from #{source}" + return nil + end + end + + log_refused_username_match(login_id, derived_username, source) + nil + end + + private + + # + # An account already holds the username this assertion derives, and nothing + # the provider asserted matched it. Somebody investigating a duplicate account + # or a failed sign in later needs to see the near miss. The assertion itself + # is never logged. + # + def log_refused_username_match(login_id, derived_username, source) + return if derived_username.blank? + return unless User.exists?(username: derived_username) + + logger.info "Refused username match for #{login_id} from #{source}" + end +end diff --git a/app/helpers/lti_helper.rb b/app/helpers/lti_helper.rb index 335b33d805..97a6de1477 100644 --- a/app/helpers/lti_helper.rb +++ b/app/helpers/lti_helper.rb @@ -7,7 +7,9 @@ def decode_lti_token(token) jti = response['jti'] exp = response['exp'] - raise "Missing jti" if jti.nil? + # An empty jti is no more usable than a missing one, it cannot be + # recorded and so it cannot be spent. + raise "Missing jti" if jti.blank? raise "Missing exp" if exp.nil? rescue JWT::DecodeError => e logger.debug "Failed to validate Lti Token: #{e}" @@ -24,4 +26,41 @@ def valid_lti_member?(member) missing = required_fields.select { |f| member[f].nil? || member[f].to_s.strip.empty? } [missing.empty?, missing] end + + # + # The identity fields an LTI member maps onto a Doubtfire user. This is the + # mapping the LTI sign in already uses. + # + def lti_member_user_id_data(member) + { + login_id: member['ext_user_username'] || member['user_id'], + email: member['email'], + username: member['email']&.split('@')&.first + } + end + + # + # Is this signed in user the person the LTI member describes? + # + # Only the login_id and the email are asserted by the platform. The username + # is derived from the local part of the email, which is not unique across + # domains, so it is never enough on its own to say a token belongs to + # somebody. + # + def lti_member_is?(member, user) + return false if user.nil? + + id_data = lti_member_user_id_data(member) + + # The login_id is the strongest thing the platform asserts about the person, + # so when both sides carry one it settles the question by itself. Falling + # through to the email after a mismatch would let a token issued for somebody + # else bind to this user on a shared or reused address. + if id_data[:login_id].present? && user.login_id.present? + return user.login_id == id_data[:login_id] + end + + # No login_id on one side or the other, so the email is all that is left. + id_data[:email].present? && user.email.present? && user.email.casecmp?(id_data[:email]) + end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index ead50cd963..38456a4027 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,2 +1,18 @@ class ApplicationMailer < ActionMailer::Base + private + + # Azure Communication Services only accepts a verified sender in From. + # Keep the existing per-user From address outside production so local mail + # previews and development SMTP retain their current behaviour. In + # production, callers may preserve the human sender as Reply-To. + def outbound_sender_headers(development_from:, reply_to: nil) + return { from: development_from } unless Rails.env.production? + + configured_sender = Doubtfire::Application.config.institution[:email_sender].presence + raise ArgumentError, 'institution email_sender must be configured in production' if configured_sender.blank? + + headers = { from: configured_sender } + headers[:reply_to] = reply_to if reply_to.present? + headers + end end diff --git a/app/mailers/communications_mailer.rb b/app/mailers/communications_mailer.rb index 95034342d9..67a6b077a6 100644 --- a/app/mailers/communications_mailer.rb +++ b/app/mailers/communications_mailer.rb @@ -11,7 +11,11 @@ def communication_email(to:, from:, subject:, body:, recipient:, sender:, unit:, @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] @unsubscribe_url = "#{@doubtfire_host}/edit_profile" - mail(to: to, from: from, subject: subject) + mail( + { to: to, subject: subject }.merge( + outbound_sender_headers(development_from: from, reply_to: from) + ) + ) end def action_log_email(payload) @@ -32,6 +36,10 @@ def action_log_email(payload) content: payload[:csv_content] } - mail(to: payload[:to], from: payload[:from], subject: payload[:subject]) + mail( + { to: payload[:to], subject: payload[:subject] }.merge( + outbound_sender_headers(development_from: payload[:from], reply_to: payload[:from]) + ) + ) end end diff --git a/app/mailers/convenor_contact_mailer.rb b/app/mailers/convenor_contact_mailer.rb index 5859399bda..3b1e71fd47 100644 --- a/app/mailers/convenor_contact_mailer.rb +++ b/app/mailers/convenor_contact_mailer.rb @@ -5,11 +5,12 @@ def request_project_membership(user, _convenor, unit, _first_name, _last_name) institution_email_domain = Doubtfire::Application.config.institution[:email_domain] admin_emails = User.admins.map(&:email) user_email = "#{user.username}@#{institution_email_domain}" - mail to: admin_emails, - from: user_email, - subject: "[#{@doubtfire_product_name}] Please add #{user.username} to #{unit.name}", - body: "The following user wishes to be added to #{unit.name} on " \ - "#{@doubtfire_product_name}:\n\nUsername: #{user.username}\nEmail: #{user_email}\n" \ - "Name: #{user.name}" + mail({ + to: admin_emails, + subject: "[#{@doubtfire_product_name}] Please add #{user.username} to #{unit.name}", + body: "The following user wishes to be added to #{unit.name} on " \ + "#{@doubtfire_product_name}:\n\nUsername: #{user.username}\nEmail: #{user_email}\n" \ + "Name: #{user.name}" + }.merge(outbound_sender_headers(development_from: user_email, reply_to: user_email))) end end diff --git a/app/mailers/d2l_result_mailer.rb b/app/mailers/d2l_result_mailer.rb index b50ff6bdb6..c066606a5a 100644 --- a/app/mailers/d2l_result_mailer.rb +++ b/app/mailers/d2l_result_mailer.rb @@ -15,6 +15,10 @@ def result_message(unit, user, result_message: 'completed', success: true) attachments['result.csv'] = File.read(path) end - mail(to: email, from: email, subject: "#{@doubtfire_product_name} #{unit.code} - D2L Grade Transfer Result") + mail( + { to: email, subject: "#{@doubtfire_product_name} #{unit.code} - D2L Grade Transfer Result" }.merge( + outbound_sender_headers(development_from: email) + ) + ) end end diff --git a/app/mailers/error_log_mailer.rb b/app/mailers/error_log_mailer.rb index 578a6f6fc3..e0e4226fc7 100644 --- a/app/mailers/error_log_mailer.rb +++ b/app/mailers/error_log_mailer.rb @@ -11,6 +11,10 @@ def error_message(subject, message, exception) backtrace = exception.backtrace&.join("\n") || 'No backtrace available' @error_log = "#{message}\n\n#{exception.message}\n\n#{backtrace}" - mail(to: email, from: email, subject: "#{@doubtfire_product_name} Error Log - #{subject}") + mail( + { to: email, subject: "#{@doubtfire_product_name} Error Log - #{subject}" }.merge( + outbound_sender_headers(development_from: email) + ) + ) end end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index f4aefa1499..9bd4bf0f96 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -5,6 +5,45 @@ def add_general @unsubscribe_url = "#{@doubtfire_host}/edit_profile" end + # Sends a single in-system notification as an email. Called by + # NotificationEmailJob, which lets delivery failures reach Sidekiq so they can + # be retried without blocking the request that created the notification. + def single_notification(notification) + add_general + + @notification = notification + @user = notification.user + + # Use the deployment's SMTP-authorised sender, with a development-safe + # fallback for older installations that have not configured one yet. + from_address = Doubtfire::Application.config.institution[:email_sender].presence || 'noreply@doubtfire.local' + + email_with_name = %("#{@user.name}" <#{@user.email}>) + subject = "#{@doubtfire_product_name}: New notification" + + # An event may ship its own pair of templates named after it, for example + # task_comment_created.html.erb and task_comment_created.text.erb. Events + # without them fall back to the generic single_notification pair. + # + # This is why a new event ticket only ever adds files and never edits this + # method: eight event tickets can run in parallel without touching each + # other's work. + mail( + to: email_with_name, + subject: subject, + template_name: event_template_name(notification.event), + **outbound_sender_headers(development_from: from_address) + ) + end + + # The event's own template if it exists, otherwise the generic one. + def event_template_name(event) + return 'single_notification' if event.blank? + return 'single_notification' unless lookup_context.exists?(event, [self.class.mailer_name], false) + + event + end + def weekly_staff_summary(unit_role, summary_stats) return nil if unit_role.nil? @@ -41,7 +80,11 @@ def weekly_staff_summary(unit_role, summary_stats) convenor_email = %("#{@convenor.name}" <#{@convenor.email}>) subject = "#{@unit.name}: Weekly Summary" - mail(to: email_with_name, from: convenor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: convenor_email, reply_to: convenor_email) + ) + ) end def weekly_student_summary(project, summary_stats, did_revert_to_pass) @@ -77,7 +120,11 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) subject = "#{project.unit.name}: Weekly Summary" - mail(to: email_with_name, from: tutor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: tutor_email, reply_to: tutor_email) + ) + ) end def top_task_desc(tt) diff --git a/app/mailers/portfolio_evidence_mailer.rb b/app/mailers/portfolio_evidence_mailer.rb index 743503c25b..03eb26a8ae 100644 --- a/app/mailers/portfolio_evidence_mailer.rb +++ b/app/mailers/portfolio_evidence_mailer.rb @@ -18,7 +18,11 @@ def task_pdf_failed(project, tasks) email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) subject = "#{project.unit.code} #{project.unit.name}: Task submission processing failed" - mail(to: email_with_name, from: tutor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: tutor_email, reply_to: tutor_email) + ) + ) end def task_pdf_ready_message(project, tasks) @@ -34,7 +38,11 @@ def task_pdf_ready_message(project, tasks) email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) subject = "#{project.unit.name}: Task PDFs ready to view" - mail(to: email_with_name, from: tutor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: tutor_email, reply_to: tutor_email) + ) + ) end def task_feedback_ready(project, tasks) @@ -51,7 +59,11 @@ def task_feedback_ready(project, tasks) email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) subject = "#{project.unit.name}: Feedback ready to review" - mail(to: email_with_name, from: tutor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: tutor_email, reply_to: tutor_email) + ) + ) end def overseer_assessment_failed(project, tasks) @@ -67,7 +79,11 @@ def overseer_assessment_failed(project, tasks) email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) subject = "#{project.unit.code} #{project.unit.name}: Automated feedback needs your attention" - mail(to: email_with_name, from: tutor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: tutor_email, reply_to: tutor_email) + ) + ) end def portfolio_ready(project) @@ -82,7 +98,11 @@ def portfolio_ready(project) email_with_name = %("#{@student.name}" <#{@student.email}>) convenor_email = %("#{@convenor.name}" <#{@convenor.email}>) subject = "#{project.unit.name}: Portfolio ready to review" - mail(to: email_with_name, from: convenor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: convenor_email, reply_to: convenor_email) + ) + ) end def portfolio_failed(project) @@ -97,6 +117,10 @@ def portfolio_failed(project) email_with_name = %("#{@student.name}" <#{@student.email}>) convenor_email = %("#{@convenor.name}" <#{@convenor.email}>) subject = "#{project.unit.name}: Portfolio failed to compile" - mail(to: email_with_name, from: convenor_email, subject: subject) + mail( + { to: email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: convenor_email, reply_to: convenor_email) + ) + ) end end diff --git a/app/mailers/tutor_note_mailer.rb b/app/mailers/tutor_note_mailer.rb index 094644aa79..5382becc4c 100644 --- a/app/mailers/tutor_note_mailer.rb +++ b/app/mailers/tutor_note_mailer.rb @@ -21,7 +21,11 @@ def notify_tutor_note(tutor_note, recipient) recipient_email_with_name = %("#{recipient.name}" <#{recipient.email}>) tutor_email = %("#{@from.name}" <#{@from.email}>) subject = "#{@unit.name}: New tutor note from #{@from.name}" - mail(to: recipient_email_with_name, from: tutor_email, subject: subject) + mail( + { to: recipient_email_with_name, subject: subject }.merge( + outbound_sender_headers(development_from: tutor_email, reply_to: tutor_email) + ) + ) end end diff --git a/app/middleware/sentry_tunnel_middleware.rb b/app/middleware/sentry_tunnel_middleware.rb index 8556130117..aac126171f 100644 --- a/app/middleware/sentry_tunnel_middleware.rb +++ b/app/middleware/sentry_tunnel_middleware.rb @@ -3,6 +3,7 @@ class SentryTunnelMiddleware PATH = '/api/client-reports'.freeze + MAX_ENVELOPE_BYTES = 256 * 1024 def initialize(app) @app = app @@ -11,17 +12,37 @@ def initialize(app) def call(env) return @app.call(env) unless env['REQUEST_METHOD'] == 'POST' && env['PATH_INFO'] == PATH - forward_envelope(env) + return payload_too_large_response if declared_body_too_large?(env) + + body = read_envelope(env) + return payload_too_large_response if body.bytesize > MAX_ENVELOPE_BYTES + + forward_envelope(env, body) [204, {}, []] end private - def forward_envelope(env) + def declared_body_too_large?(env) + length = Integer(env['CONTENT_LENGTH'], exception: false) + length && length > MAX_ENVELOPE_BYTES + end + + def read_envelope(env) + input = env.fetch('rack.input') + input.read(MAX_ENVELOPE_BYTES + 1).to_s + ensure + input.rewind if input.respond_to?(:rewind) + end + + def payload_too_large_response + [413, { 'content-length' => '0' }, []] + end + + def forward_envelope(env, body) envelope_url = sentry_envelope_url return if envelope_url.blank? - body = env['rack.input'].read return if body.blank? RestClient::Request.execute( @@ -36,8 +57,6 @@ def forward_envelope(env) Rails.logger.warn "Unable to forward Sentry envelope: #{e.class} #{e.response&.code}" rescue RestClient::Exception, SocketError, Timeout::Error => e Rails.logger.warn "Unable to forward Sentry envelope: #{e.class}" - ensure - env['rack.input'].rewind if env['rack.input'].respond_to?(:rewind) end def sentry_headers(env) diff --git a/app/models/comments/extension_comment.rb b/app/models/comments/extension_comment.rb index abd9d1030c..b615ed0575 100644 --- a/app/models/comments/extension_comment.rb +++ b/app/models/comments/extension_comment.rb @@ -1,6 +1,21 @@ class ExtensionComment < TaskComment belongs_to :assessor, class_name: 'User', optional: true + # The status that triggered a resubmission extension. It is nil on extensions + # a student asked for, which is what tells the two kinds apart. + belongs_to :task_status, optional: true + + # An extension OnTrack worked out for itself when staff sent the task back for + # more work, rather than one a student asked for. + # + # Do not call this "automatic". #assess_extension already uses that word for + # something else, an extension a student requested that was approved without a + # person weighing it up, and one word meaning two things in one class is how + # the wrong branch gets taken. + def resubmission_extension? + task_status.present? + end + def serialize(user) json = super(user) json[:granted] = extension_granted @@ -9,6 +24,8 @@ def serialize(user) json[:weeks_requested] = extension_weeks json[:extension_response] = extension_response json[:task_status] = task.status + json[:resubmission_extension] = resubmission_extension? + json[:source_status] = resubmission_extension? ? task_status.status_key : nil json end @@ -27,26 +44,40 @@ def mark_as_read(user, unit = self.unit) super if assessed? || user == project.student || user != recipient end - def assess_extension(user, granted, automatic = false) + # Assess an extension a student asked for. `auto_approved` says the unit + # approved it without a person weighing it up, which only changes the wording + # the student sees. It is not the same idea as #resubmission_extension?, which + # is about where the extension came from rather than who signed it off. + def assess_extension(user, granted, auto_approved = false) if self.assessed? - self.errors[:extension] << 'has already been assessed' + errors.add(:extension, 'could not be applied') + return false + end + + can_apply = self.task.can_apply_for_extension? + should_grant = granted && can_apply + + if should_grant && !self.task.grant_extension(user, extension_weeks) + errors.add(:extension, 'could not be applied') return false end self.assessor = user self.date_extension_assessed = Time.zone.now - self.extension_granted = granted && self.task.can_apply_for_extension? + self.extension_granted = should_grant + + should_notify = true if self.extension_granted - self.task.grant_extension(user, extension_weeks) - if automatic + if auto_approved self.extension_response = "Time extended to #{self.task.due_date.strftime('%a %b %e')}" else self.extension_response = "Extension granted to #{self.task.due_date.strftime('%a %b %e')}" end - elsif !self.task.can_apply_for_extension? && granted + elsif !can_apply && granted self.extension_response = "Extension cannot be granted as deadline has been reached" - errors[:extension] << 'cannot be granted as deadline has been reached' + errors.add(:extension, 'cannot be granted as deadline has been reached') + should_notify = false else self.extension_response = "Extension rejected" end @@ -54,5 +85,21 @@ def assess_extension(user, granted, automatic = false) # Now make sure to read it by the main tutor - even if assessed by someone else super_mark_as_read(project.tutor_for(task.task_definition)) save! + + if should_notify + begin + NotificationService.notify( + user: project.student, + type: 'extension', + event: 'extension_assessed', + message: extension_response, + link: "/projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" + ) + rescue StandardError => e + Rails.logger.error "Failed to notify student about extension assessment: #{e.message}" + end + end + + true end end diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb index c74883d014..39456571d2 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -135,7 +135,7 @@ def attachment_mime_type end def remove_comment_read_entry(user) - CommentsReadReceipts.delete_all(user: user, task_comment: self) + CommentsReadReceipts.where(user: user, task_comment: self).delete_all end def mark_as_read(user, unit = self.unit) diff --git a/app/models/consumed_lti_token.rb b/app/models/consumed_lti_token.rb new file mode 100644 index 0000000000..c2bbfa8b6c --- /dev/null +++ b/app/models/consumed_lti_token.rb @@ -0,0 +1,47 @@ +# +# Records the id of an LTI token that has been used, and the user it was spent +# by, so the roles it carries are applied exactly once. +# +class ConsumedLtiToken < ApplicationRecord + # + # Raised when the jti of a token is already recorded. Only the insert of the + # record raises this, so it can never be confused with an unrelated unique + # index failure somewhere else in the enrolment. + # + class AlreadyUsed < StandardError; end + + belongs_to :user, inverse_of: :consumed_lti_tokens + + validates :jti, presence: true + validates :expires_at, presence: true + + scope :expired, -> { where('expires_at < ?', Time.zone.now) } + + # + # Record the use of a decoded LTI token. The unique index on jti means a + # replay, including a concurrent one, fails on the insert rather than on a + # read. + # + def self.consume!(token, user:) + create!(jti: token['jti'], user: user, expires_at: Time.zone.at(token['exp'].to_i)) + rescue ActiveRecord::RecordNotUnique + raise AlreadyUsed + end + + # + # Was this token spent by this user? A launch session presents the one token + # on every mount of the LTI dashboard, so the same person presenting it again + # is not a replay. Anybody else is. + # + def spent_by?(user) + !user.nil? && user_id == user.id + end + + # + # A token that has passed its expiry can no longer be replayed, so the row + # recording it can go. Called from the maintenance:cleanup rake task. + # + def self.destroy_expired_tokens + expired.destroy_all + end +end diff --git a/app/models/group.rb b/app/models/group.rb index fec42947a6..a992525cc8 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -135,24 +135,27 @@ def switch_to_tutorial tutorial if group_set.keep_groups_in_same_class && has_active_group_members? projects.each do |proj| - # We need to remove members to break the circular dependency and switch tutorial - remove_member(proj) + # These membership changes are temporary while moving tutorial, + # so they must not generate leave/join notifications. + remove_member(proj, notify: false) te = proj.enrol_in tutorial unless te.valid? raise "Unable to move group as #{proj.student.name} could not switch tutorial." end - add_member(proj) + add_member(proj, notify: false) end end self.save! end end - def add_member(project) + def add_member(project, notify: true) gm = project.group_membership_for_groupset(group_set) + membership_changed = gm.nil? || !gm.active? || gm.group_id != id + if gm.nil? gm = GroupMembership.create(group: self, project: project) group_memberships << gm @@ -164,16 +167,41 @@ def add_member(project) gm.active = true gm.save! + notify_group_membership_change(project, 'added to') if notify && membership_changed + gm end - def remove_member(project) + def remove_member(project, notify: true) gm = group_memberships.where(project: project).first + was_active = gm.active? + gm.active = false - gm.save + saved = gm.save + + notify_group_membership_change(project, 'removed from') if notify && saved && was_active + self end + def notify_group_membership_change(project, change) + student = project.student + return if student.blank? + + NotificationService.notify( + user: student, + type: 'general', + event: 'group_membership_changed', + message: "You have been #{change} group #{name} in #{unit.code}.", + link: "/projects/#{project.id}/groups" + ) + rescue StandardError => e + logger.error( + "Failed to raise group_membership_changed notification for project #{project.id}: #{e.message}" + ) + end + + private :notify_group_membership_change # # check if the project is the same as the current submission # diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 0000000000..db780e7e43 --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,51 @@ +class Notification < ApplicationRecord + belongs_to :user + + # Notification categories. The first three map onto the existing user + # preference columns (receive_task/feedback/portfolio_notifications) so that a + # single category toggle gates every delivery channel (in-app, email, push). + TYPES = %w[task feedback portfolio extension general].freeze + + # `notification_type` is the category the user's preferences switch on. + # `event` is the specific thing that happened within that category, e.g. + # 'task_comment_created'. It is free text so a new event ticket does not have + # to edit this model, but it is required so every notification can be traced + # back to the code that raised it. + + # Maps a notification type to the user preference column that gates it. + # Types without an entry here are always delivered. + PREFERENCE_FOR_TYPE = { + 'task' => :receive_task_notifications, + 'feedback' => :receive_feedback_notifications, + 'portfolio' => :receive_portfolio_notifications + }.freeze + + validates :notification_type, presence: true, inclusion: { in: TYPES } + validates :event, presence: true, length: { maximum: 255 } + validates :message, presence: true, length: { maximum: 500 } + validates :dedupe_key, length: { maximum: 191 }, allow_nil: true + + # Queue the email only once the transaction that created the notification has + # committed. Several callers raise notifications from inside a transaction, + # for example a tutorial enrolment being destroyed removes the student from + # their group, and a worker that picked the job up before the commit could not + # see the row yet. + after_commit :queue_email_delivery, on: :create + + scope :unread, -> { where(read_at: nil) } + scope :recent_first, -> { order(created_at: :desc) } + + def read? + read_at.present? + end + + def mark_read! + update!(read_at: Time.zone.now) unless read? + end + + private + + def queue_email_delivery + NotificationService.queue_email(self) + end +end diff --git a/app/models/peer_progress_snapshot.rb b/app/models/peer_progress_snapshot.rb new file mode 100644 index 0000000000..fe5dab6aef --- /dev/null +++ b/app/models/peer_progress_snapshot.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +class PeerProgressSnapshot < ApplicationRecord + # MariaDB exposes its JSON-compatible LONGTEXT column as text to the mysql2 + # adapter. Declaring the logical type explicitly keeps Hash casting identical + # on MariaDB and native-JSON MySQL deployments. + attribute :status_counts, :json + + belongs_to :unit, + inverse_of: :peer_progress_snapshots + + belongs_to :task_definition, + inverse_of: :peer_progress_snapshots + + validates :target_grade, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + }, + uniqueness: { + scope: %i[unit_id task_definition_id] + } + + validates :submitted_percentage, + numericality: { + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 + }, + allow_nil: true + + validates :submitted_count, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + }, + allow_nil: true + + validates :cohort_size, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + } + + validates :calculated_at, + presence: true + + validate :task_definition_belongs_to_unit + validate :target_grade_enabled_for_unit + validate :target_grade_covers_task + validate :percentage_requires_non_empty_cohort + validate :submitted_count_fits_cohort + validate :status_counts_cover_the_cohort + + private + + def task_definition_belongs_to_unit + return if unit.blank? || task_definition.blank? + return if task_definition.unit_id == unit_id + + errors.add( + :task_definition, + 'must belong to the same unit' + ) + end + + def target_grade_enabled_for_unit + return if unit.blank? || target_grade.nil? + return if unit.grade_value?(target_grade) + + errors.add( + :target_grade, + 'must be enabled for the unit' + ) + end + + def target_grade_covers_task + return if task_definition.blank? || target_grade.nil? + return if target_grade >= task_definition.target_grade + + errors.add( + :target_grade, + 'must be at least the task definition target grade' + ) + end + + def percentage_requires_non_empty_cohort + return if submitted_percentage.nil? + return if cohort_size.nil? + return if cohort_size.positive? + + errors.add( + :submitted_percentage, + 'must be blank when cohort size is zero' + ) + end + + def submitted_count_fits_cohort + return if submitted_count.nil? || cohort_size.nil? + return if submitted_count <= cohort_size + + errors.add( + :submitted_count, + 'must not exceed the cohort size' + ) + end + + def status_counts_cover_the_cohort + return if status_counts.nil? + + keys_valid = status_counts.is_a?(Hash) && + status_counts.keys.map(&:to_s).sort == + PeerProgressDistributionPolicy::STATUS_KEYS.sort + values_valid = status_counts.is_a?(Hash) && + status_counts.values.all? do |value| + value.is_a?(Integer) && value >= 0 + end + + unless keys_valid && values_valid + errors.add( + :status_counts, + 'must contain every supported task status with non-negative integer counts' + ) + return + end + + return if PeerProgressDistributionPolicy.valid_status_counts?( + status_counts, + cohort_size: cohort_size + ) + + errors.add( + :status_counts, + 'must sum to the cohort size' + ) + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 64dc33ed4e..016b32ada0 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -35,6 +35,10 @@ class Project < ApplicationRecord has_many :staff_notes, dependent: :destroy has_many :engagements, dependent: :destroy, inverse_of: :project + before_create :record_target_grade_change + before_update :record_target_grade_change, + if: :will_save_change_to_target_grade? + # Callbacks - methods called are private before_destroy :can_destroy? @@ -174,10 +178,30 @@ def enrol_in(tutorial) else # there is an existing enrolment... tutorial_enrolment.tutorial = tutorial tutorial_enrolment.update!(tutorial_id: tutorial.id) + notify_tutorial_changed(tutorial) end tutorial_enrolment end + def notify_tutorial_changed(tutorial) + student = self.student + return if student.blank? + + NotificationService.notify( + user: student, + type: 'general', + event: 'tutorial_changed', + message: "You have been moved to tutorial #{tutorial.abbreviation} in #{unit.code}. It meets on #{tutorial.meeting_day} at #{tutorial.meeting_time}.", + link: "/projects/#{id}/dashboard" + ) + rescue StandardError => e + logger.error( + "Failed to raise tutorial_changed notification for project #{id}: #{e.message}" + ) + end + + private :notify_tutorial_changed + def enrolled_in?(tutorial) tutorial_enrolments.select { |e| e.tutorial_id == tutorial.id }.count > 0 || tutorial_enrolments.where(tutorial_id: tutorial.id).count > 0 end @@ -278,7 +302,7 @@ def reference_date end def task_details_for_shallow_serializer(user) - tasks + task_rows = tasks .joins(:task_status) .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')") .joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}") @@ -294,27 +318,65 @@ def task_details_for_shallow_serializer(user) 'completion_date', 'times_assessed', 'submission_date', 'grade', 'quality_pts', 'include_in_portfolio', 'grade' ) - .map do |r| - t = Task.find(r.id) - { - id: r.id, - status: TaskStatus.id_to_key(r.status_id), - task_definition_id: r.task_definition_id, - include_in_portfolio: r.include_in_portfolio, - times_assessed: r.times_assessed, - grade: r.grade, - quality_pts: r.quality_pts, - num_new_comments: r.number_unread, - similarity_flag: AuthorisationHelpers.authorise?(user, t, :view_plagiarism) ? r.similar_to_count > 0 : false, - extensions: t.extensions, - scorm_extensions: t.scorm_extensions, - due_date: t.due_date, - submission_date: t.submission_date, - completion_date: t.completion_date, - target_start_date: t.target_start_date, - target_due_date: t.target_due_date - } - end + .to_a + + # The aggregate rows intentionally select only the fields used directly in + # the response. Reload their complete Task records in one batch so due-date + # and authorisation helpers can use preloaded associations instead of doing + # a Task.find (plus project/unit/task-definition lookups) for every task. + tasks_by_id = Task + .where(id: task_rows.map(&:id)) + .preload(:task_definition, project: %i[unit user]) + .index_by(&:id) + + task_ids = task_rows.map(&:id) + + feedback_task_ids = TaskComment + .where(task_id: task_ids) + .where(content_type: %w[text audio image pdf discussion]) + .where(user_id: unit.staff.select(:user_id)) + .where.not("COALESCE(comment, '') LIKE ?", '**Automated Message:%') + .where( + <<~SQL.squish, + task_comments.created_at >= COALESCE( + ( + SELECT MIN(ready_comments.created_at) + FROM task_comments ready_comments + WHERE ready_comments.task_id = task_comments.task_id + AND ready_comments.content_type = 'status' + AND ready_comments.task_status_id = ? + ), + task_comments.created_at + ) + SQL + TaskStatus.ready_for_feedback.id + ) + .distinct + .pluck(:task_id) + .to_set + + task_rows.map do |r| + t = tasks_by_id.fetch(r.id) + { + id: r.id, + status: TaskStatus.id_to_key(r.status_id), + task_definition_id: r.task_definition_id, + include_in_portfolio: r.include_in_portfolio, + times_assessed: r.times_assessed, + grade: r.grade, + quality_pts: r.quality_pts, + num_new_comments: r.number_unread, + has_feedback: feedback_task_ids.include?(r.id), + similarity_flag: AuthorisationHelpers.authorise?(user, t, :view_plagiarism) ? r.similar_to_count > 0 : false, + extensions: t.extensions, + scorm_extensions: t.scorm_extensions, + due_date: t.due_date, + submission_date: t.submission_date, + completion_date: t.completion_date, + target_start_date: t.target_start_date, + target_due_date: t.target_due_date + } + end end def assigned_tasks @@ -636,7 +698,7 @@ def status_for_task_definition(td) # task if the task does not exist for this project. # def task_for_task_definition(td) - logger.debug "Finding task #{td.abbreviation} for project #{log_details}" + logger.debug "Finding task #{td.abbreviation} for project_id=#{id}" result = tasks.where(task_definition: td).first if result.nil? begin @@ -718,6 +780,10 @@ def escalation_attempts_remaining private + def record_target_grade_change + self.target_grade_changed_at = Time.current + end + def can_destroy? return true if tutorial_enrolments.count == 0 diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000000..f235c1a613 --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,91 @@ +# One browser registered to receive web push notifications. +# +# The endpoint is the URL the push service gave that browser. It identifies the +# browser, not the person, so it is unique across the whole table: if the same +# browser signs in as a different user the registration moves across instead of +# being duplicated. PushSubscriptionsApi does that move. +# +# The endpoint arrives from the client and the api later makes an outbound POST +# to it, so it is not free text. It has to be an https URL belonging to a push +# service we recognise, or a signed in user could point the api at an internal +# host and use it to make requests on their behalf. See PUSH_SERVICE_HOSTS. +class PushSubscription < ApplicationRecord + # Exact hosts. One per push service. + # + # fcm.googleapis.com Chrome, Opera, Brave + # android.googleapis.com older Chrome on Android + # updates.push.services.mozilla.com Firefox + # + # A verified Edge 151 subscription on macOS used WNS even though Edge is + # Chromium. Endpoint selection can vary by platform or release, so these + # labels are observations rather than a browser-detection contract. + PUSH_SERVICE_HOSTS = %w[ + fcm.googleapis.com + android.googleapis.com + updates.push.services.mozilla.com + ].freeze + + # Suffixes, for the services that shard across per-region subdomains. Matched + # with a leading dot so "evil-notify.windows.com" cannot pass as a subdomain + # of "notify.windows.com". + # + # *.notify.windows.com WNS, current Edge observed + # *.push.services.microsoft.com WNS, current + # *.push.apple.com Safari, iOS 16.4+ + # + # Not legacy. Edge 151 on macOS subscribed through + # wns2-bl2p.notify.windows.com when this was checked on 27 Aug 2026. Do not + # infer a browser only from an endpoint host; the testing guide records the + # scoped observation and the allow-list accepts the supported services. + PUSH_SERVICE_HOST_SUFFIXES = %w[ + .notify.windows.com + .push.services.microsoft.com + .push.apple.com + ].freeze + + belongs_to :user + + validates :endpoint, presence: true, uniqueness: true, length: { maximum: 500 } + validates :p256dh, presence: true, length: { maximum: 255 } + validates :auth, presence: true, length: { maximum: 255 } + + validate :endpoint_is_a_known_push_service + + # True when this endpoint is one we are willing to send to. + # + # Also called at delivery time, because rows written before this validation + # existed were never checked. Keep it a class method for that reason. + def self.push_service_endpoint?(endpoint) + return false if endpoint.blank? + + uri = URI.parse(endpoint.to_s) + + # https only. http would send the encrypted payload in the clear and is not + # something any real push service offers. + return false unless uri.is_a?(URI::HTTPS) + + # user:password@host is a redirect trick, and a non standard port is a sign + # somebody is aiming this somewhere it should not go. No push service uses + # either. + return false if uri.userinfo.present? + return false unless uri.port == 443 + + host = uri.host.to_s.downcase + return false if host.blank? + + PUSH_SERVICE_HOSTS.include?(host) || + PUSH_SERVICE_HOST_SUFFIXES.any? { |suffix| host.end_with?(suffix) } + rescue URI::InvalidURIError + false + end + + private + + def endpoint_is_a_known_push_service + return if endpoint.blank? # presence validation already covers this + + return if self.class.push_service_endpoint?(endpoint) + + errors.add(:endpoint, 'must be an https URL belonging to a recognised push service') + end +end diff --git a/app/models/similarity/unit_similarity_module.rb b/app/models/similarity/unit_similarity_module.rb index 819dc288c6..3a5567bc63 100644 --- a/app/models/similarity/unit_similarity_module.rb +++ b/app/models/similarity/unit_similarity_module.rb @@ -107,8 +107,9 @@ def check_jplag_similarity(force: false) pwd = FileUtils.pwd completed_all_checks = true - # making temp directory for unit - jplag - root_work_dir = Rails.root.join("tmp", "jplag", "#{code}-#{id}") + # Unit codes are editable and may contain path or shell metacharacters. Keep + # the transient workspace derived only from database integer identifiers. + root_work_dir = Rails.root.join('tmp', 'jplag', "unit-#{id.to_i}") begin logger.info "Checking plagiarsm for unit #{code} - #{name} (id=#{id})" @@ -134,7 +135,7 @@ def check_jplag_similarity(force: false) FileUtils.mkdir_p(root_work_dir) # Init work directory for each task definition - tasks_dir = root_work_dir.join(td.id.to_s) + tasks_dir = root_work_dir.join(td.id.to_i.to_s) FileUtils.mkdir_p(tasks_dir) # There are new tasks, check these with JPLAG @@ -161,6 +162,8 @@ def check_jplag_similarity(force: false) end ensure FileUtils.chdir(pwd) if FileUtils.pwd != pwd + logger.info "Deleting JPlag work directory for unit #{id}: #{root_work_dir}" + FileUtils.rm_rf(root_work_dir) end self @@ -173,9 +176,6 @@ def update_moss_plagiarism_stats moss = MossRuby.new(moss_key) task_definitions.where(plagiarism_updated: true).find_each do |td| - td.plagiarism_updated = false - td.save - # Get results url = td.plagiarism_report_url logger.debug "Processing MOSS results #{url}" @@ -184,13 +184,18 @@ def update_moss_plagiarism_stats results = moss.extract_results(url, warn_pct, ->(line) { puts line }) + # Track whether every match linked cleanly. A match that fails is logged and + # skipped so the rest still process, but the definition is left flagged for + # the next scan instead of being silently marked done. + completed = true + # Use results results.each do |match| task_id1 = %r{.*/(\d+)/$}.match(match[0][:filename])[1] task_id2 = %r{.*/(\d+)/$}.match(match[1][:filename])[1] - t1 = Task.find(task_id1) - t2 = Task.find(task_id2) + t1 = Task.find_by(id: task_id1) + t2 = Task.find_by(id: task_id2) if t1.nil? || t2.nil? logger.error "Could not find tasks #{task_id1} or #{task_id2} for plagiarism stats check!" @@ -210,6 +215,22 @@ def update_moss_plagiarism_stats else # just link the individuals... create_moss_plagiarism_link(t1, t2, match, warn_pct) end + rescue StandardError => e + # One bad match must not abort the rest of the import for this definition, + # but the definition must be retried, so remember that it did not complete. + completed = false + logger.error "Failed to process MOSS match for task definition #{td.id}: #{e.message}" + next + end + + # Clear the flag only after a clean pass, and only while the report we just + # processed is still the current one. A concurrent scan that produced a newer + # report writes a new url and re-flags, so matching on url leaves that newer + # flag intact, and a partial failure is retried rather than dropped. + if completed + # rubocop:disable Rails/SkipsModelValidations + TaskDefinition.where(id: td.id, plagiarism_report_url: url).update_all(plagiarism_updated: false) + # rubocop:enable Rails/SkipsModelValidations end end @@ -241,12 +262,15 @@ def run_jplag_on_done_files(task_definition, tasks_dir, tasks_with_files, report similarity_pct = task_definition.plagiarism_warn_pct return if similarity_pct.nil? - # Check if the directory exists and create it if it doesn't - results_dir = File.dirname(report_path) - system("docker exec -i jplag sh -c 'if [ ! -d \"#{results_dir}\" ]; then mkdir -p \"#{results_dir}\"; fi'") || raise('Failed to create JPlag results directory') + # Pass every derived path as its own argv entry. Do not put unit, task, or + # report data through a shell in the API container or the JPlag container. + results_dir = File.dirname(report_path).to_s + system('docker', 'exec', '-i', 'jplag', 'mkdir', '-p', results_dir) || + raise('Failed to create JPlag results directory') - # Remove existing result file if it exists - system("docker exec -i jplag sh -c 'if [ -f \"#{report_path}\" ]; then rm \"#{report_path}\"; fi'") || raise('Failed to remove previous JPlag report') + # rm -f is already successful when the old report is absent. + system('docker', 'exec', '-i', 'jplag', 'rm', '-f', report_path.to_s) || + raise('Failed to remove previous JPlag report') # Extract task resources for base code use_base_code = false @@ -295,51 +319,49 @@ def run_jplag_on_done_files(task_definition, tasks_dir, tasks_with_files, report end logger.info "Starting JPLAG container to run on #{tasks_dir}" - root_dir = Rails.root.to_s - tasks_dir_split = tasks_dir.to_s.split(root_dir)[1] + tasks_dir_in_container = Pathname.new('/').join(tasks_dir.relative_path_from(Rails.root)).to_s file_lang = task_definition.similarity_language.to_s # Convert pct to decimal similarity_threshold = similarity_pct.to_f / 100 min_tokens = Doubtfire::Application.config.jplag_min_tokens.to_i - # If empty, let JPlag set the default per-language - min_token_string = min_tokens <= 0 ? "" : "--min-tokens=#{min_tokens}" - - base_code_string = use_base_code ? "--base-code=#{tasks_dir_split}/base" : "" - skip_cluster_check = Doubtfire::Application.config.jplag_skip_cluster_check - skip_cluster_string = skip_cluster_check ? '--cluster-skip' : '' max_shown_comparisons = Doubtfire::Application.config.jplag_max_shown_comparisons max_shown_comparisons = 2500 if max_shown_comparisons.nil? # Run JPLAG on the extracted files. JPlag container should already be in the /jplag/ workdir. docker_command = [ - "docker exec -i jplag", - "java -jar jplag-jar-with-dependencies.jar", - "--skip-version-check", - "#{tasks_dir_split}/submissions", - base_code_string, - "-l #{file_lang}", + 'docker', 'exec', '-i', 'jplag', + 'java', '-jar', 'jplag-jar-with-dependencies.jar', + '--skip-version-check', + File.join(tasks_dir_in_container, 'submissions') + ] + docker_command << "--base-code=#{File.join(tasks_dir_in_container, 'base')}" if use_base_code + docker_command.push( + '-l', file_lang, "--similarity-threshold=#{similarity_threshold}", - "--shown-comparisons=#{max_shown_comparisons}", - min_token_string, - skip_cluster_string, - "-M RUN", - "-r #{report_path.delete_suffix('.jplag')}", - "--overwrite" - ].join(" ") - - logger.debug "Executing command: #{docker_command}" - system(docker_command) - - # Delete the extracted code files from tmp - tmp_dir = Rails.root.join("tmp/jplag") - logger.info "Deleting files in: #{tmp_dir}" - logger.info "Files to delete: #{Dir.glob("#{tmp_dir}/*")}" - FileUtils.rm_rf(Dir.glob("#{tmp_dir}/*")) + "--shown-comparisons=#{max_shown_comparisons}" + ) + docker_command << "--min-tokens=#{min_tokens}" if min_tokens.positive? + docker_command << '--cluster-skip' if skip_cluster_check + docker_command.push( + '-M', 'RUN', + '-r', report_path.to_s.delete_suffix('.jplag'), + '--overwrite' + ) + + logger.debug "Executing command argv: #{docker_command.inspect}" + system(*docker_command) || raise('Failed to run JPlag similarity check') + self + ensure + # Each unit has its own root work directory. Only remove this task + # definition's extracted files here: another unit may be running in + # parallel under tmp/jplag and its workspace must remain untouched. + logger.info "Deleting JPlag task work directory: #{tasks_dir}" + FileUtils.rm_rf(tasks_dir) end def process_jplag_plagiarism_report(path, warn_pct, is_group) @@ -374,8 +396,8 @@ def process_jplag_plagiarism_report(path, warn_pct, is_group) task2_id = entry.name.split('/')[2].to_i end end - first_submission = Task.find(task1_id) if task1_id - second_submission = Task.find(task2_id) if task2_id + first_submission = Task.find_by(id: task1_id) if task1_id + second_submission = Task.find_by(id: task2_id) if task2_id if first_submission.nil? || second_submission.nil? logger.error "Could not find tasks #{comparison[:first_submission]} or #{comparison[:second_submission]} for plagiarism stats check!" diff --git a/app/models/task.rb b/app/models/task.rb index a48148c8a3..994b01da8c 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -292,13 +292,39 @@ def processing_pdf? folder_exists_in_new? || folder_exists_in_process? end + # The time zone this task's deadlines are read in. + # + # A deadline written as a day belongs to the student's day, so the zone comes + # from the campus the student is enrolled at. Campus#timezone already falls + # back to the application zone when the column is not set, and a project with + # no campus falls back to the same place, so an install that has not filled in + # campus time zones behaves exactly as it did before. Nothing here depends on + # config.time_zone being set to anything in particular. + def deadline_time_zone + name = project&.campus&.timezone + zone = ActiveSupport::TimeZone[name] if name.present? + + zone || Time.zone + end + + # The calendar day a deadline falls on, read in this task's own zone. + # + # Reading it in whatever zone the value happened to be loaded in is what let + # the day move. A campus on Australian time changes its offset from UTC by an + # hour twice a year, so the same wall clock deadline sat on one UTC day in + # summer and the next one in winter, and every date built from those parts + # drifted with it. + def deadline_date(value) + value.in_time_zone(deadline_time_zone).to_date + end + # Get the raw extension date - with extensions representing weeks def raw_extension_date - target_date.to_date + extensions.weeks + deadline_date(target_date) + extensions.weeks end def max_date_with_spec_con_days - task_definition.due_date.to_date + project.spec_con_days.days + deadline_date(task_definition.due_date) + project.spec_con_days.days end # Get the adjusted extension date, which ensures it is never past the due date @@ -376,6 +402,123 @@ def grant_extension(by_user, weeks) end end + # + # The effective resubmission deadline + # + # When staff send a task back for more work the student needs time to do that + # work, so a task whose deadline is close is extended by the unit's + # resubmission extension. The rule itself is the four methods below, so a + # change to the rule is a change in one place. + # + # See docs/submission-lifecycle/effective-resubmission-deadline.md + # + + # The statuses that hand a task back to the student for more work + def resubmission_extension_statuses + [TaskStatus.fix_and_resubmit, TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.demonstrate] + end + + # How close the deadline has to be before a resubmission earns an extension + def resubmission_extension_window + 7.days + end + + # How many weeks the unit adds when a resubmission earns an extension + def resubmission_extension_weeks + unit.extension_weeks_on_resubmit_request + end + + # The moment this task's deadline actually passes. + # + # A deadline set as a day runs to the end of that day anywhere on earth, and + # which day that is is read in the task's own zone. This is the "effective + # deadline" the ticket is named after and it is the one value the window, the + # late check and the interface should all agree on. + def effective_deadline + to_same_day_anywhere_on_earth(due_date) + end + + # The far edge of the window: seven calendar days after this assessment, in + # the task's own zone. + # + # The window is added as a duration to a time in that zone, so it lands at the + # same wall clock seven days later even when the clocks change in between. The + # week Melbourne moves onto daylight saving is 167 real hours long and the + # week it moves off is 169, and counting either as a flat 168 moved the edge of + # the window by an hour. + def resubmission_extension_window_end(assess_date = Time.zone.now) + assess_date.in_time_zone(deadline_time_zone) + resubmission_extension_window + end + + # Is the deadline close enough, at the moment of this assessment, for the + # resubmission extension to apply? The assessment's own time is used rather + # than the wall clock, so that reprocessing an event gives the answer it gave + # when it happened, and so dependent tasks fixed recursively are judged at the + # same moment as the task that triggered them. + # + # Both sides of this comparison are resolved in the task's own zone rather + # than in whatever the application zone happens to be, so the answer does not + # depend on config.time_zone being set. + def resubmission_extension_window_open?(assess_date = Time.zone.now) + effective_deadline < resubmission_extension_window_end(assess_date) + end + + # The resubmission extension recorded for the current round of feedback, or + # nil if this round has not earned one. A round starts when the + # student submits, which is the same signal times_assessed uses, so a genuine + # resubmission earns a new extension while a repeated assessment, a re-save or + # a duplicate event does not. + def resubmission_extension_comment + return nil if submission_date.nil? + + comments + .where(type: 'ExtensionComment') + .where.not(task_status_id: nil) + .where('date_extension_assessed >= ?', submission_date) + .order(:id) + .last + end + + # Apply the resubmission extension for this assessment, if the rule calls for + # one and this round of feedback has not already had one. Returns the comment + # recording the extension, or nil when no extension was applied. + def grant_resubmission_extension(status, by_user, assess_date = Time.zone.now) + return nil unless resubmission_extension_statuses.include?(status) + return nil unless resubmission_extension_weeks > 0 + return nil unless can_apply_for_extension? + return nil unless resubmission_extension_window_open?(assess_date) + + # One resubmission extension per round of feedback - reprocessing must not move + # the deadline a second time + return nil if resubmission_extension_comment.present? + + weeks = [resubmission_extension_weeks, weeks_can_extend].min + return nil unless grant_extension(by_user, weeks) + + record_resubmission_extension(status, by_user, assess_date, weeks) + end + + # Record why the deadline moved and which assessment moved it, so the + # interface and the notifications can explain the change, and so a repeat of + # the same assessment can see that it has already been handled. + def record_resubmission_extension(status, by_user, assess_date, weeks) + extension = ExtensionComment.new + extension.task = self + extension.user = by_user + extension.recipient = by_user == project.student ? tutor : project.student + extension.content_type = :extension + extension.task_status = status + extension.assessor = by_user + extension.extension_weeks = weeks + extension.extension_granted = true + extension.date_extension_assessed = assess_date + extension.comment = "**Automated Message:** This task was set to #{status.name} within a week of its deadline, so it was extended by #{weeks} #{'week'.pluralize(weeks)} to give you time to resubmit." + extension.extension_response = "Time extended to #{due_date.strftime('%a %b %e')}" + extension.save! + + extension + end + # Applying for a scorm extension will create a scorm extension comment def apply_for_scorm_extension(user, text) extension = ScormExtensionComment.create @@ -605,6 +748,10 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: # State transitions based upon the trigger # + # Remember the status before the transition so we can tell, at the end, + # whether it actually changed. An unchanged status must not notify (EN-E02). + status_id_before_transition = task_status_id + status = TaskStatus.status_for_name(trigger) case status @@ -676,9 +823,80 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: end end + # EN-V06: tell the responsible tutor when a student submits for marking. + notify_tutor_of_task_submission(by_user, role, status_id_before_transition, group_transition) + + # EN-E02: tell the student when a staff member changed their task's status. + notify_student_of_status_change(by_user, role, status_id_before_transition) + true end + # Tell the responsible tutor when a student's task genuinely moves into the + # ready-for-feedback state. EN-E02 shares this transition seam, but its + # tutor-only role guard is deliberately disjoint from this student-only one, + # so one transition cannot raise both events. + # + # A group submission fans the same transition out to every member task. Only + # the original action notifies; internal group transitions are suppressed so + # one logical submission cannot amplify into duplicate tutor emails. + def notify_tutor_of_task_submission(by_user, role, previous_status_id, group_transition) + return unless [:student, :group_member].include?(role) + return if group_transition + return unless task_status == TaskStatus.ready_for_feedback + return if task_status_id == previous_status_id + + recipient = project&.tutor_for(task_definition) + student = project&.student + return if recipient.blank? || student.blank? || recipient == by_user + + product_name = Doubtfire::Application.config.institution[:product_name] + + NotificationService.notify( + user: recipient, + type: 'task', + event: 'task_submitted', + message: "#{student.name} submitted #{task_definition.name} for marking in #{product_name}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise task_submitted notification for task #{id}: #{e.message}" + end + + # Tell the student that a staff member changed the status of their task. + # + # Only a tutor's action notifies (role == :tutor); a student changing their + # own task must never email themselves. And only a real change notifies: an + # unchanged status is a no-op. + # + # The new status value is deliberately kept out of the notification, the same + # way the comment text is in notify_comment_recipient. The email is a prompt to + # come back to OnTrack, not a copy of the result. + # + # Raising a notification must never roll back the transition, so failures are + # logged and swallowed. NotificationService already rescues mail errors; this + # catches the record write and anything else unexpected. + def notify_student_of_status_change(by_user, role, previous_status_id) + return unless role == :tutor + return if task_status_id == previous_status_id + + recipient = project&.student + # recipient == by_user is belt and braces: once role == :tutor the actor + # cannot be the student, since user_role checks user == student first. Kept + # so a future change to user_role cannot start emailing someone themselves. + return if recipient.blank? || recipient == by_user + + NotificationService.notify( + user: recipient, + type: 'task', + event: 'task_status_changed', + message: "#{by_user.name} updated the status of #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise task_status_changed notification for task #{id}: #{e.message}" + end + def has_discussed_in_class_comment? comments.where(content_type: 'discussed_in_class').exists? end @@ -781,13 +999,10 @@ def assess(task_status, assessor, assess_date = Time.zone.now, recursive_fix = f else self.completion_date = nil - # Grant an extension on fix if due date is within 1 week - case task_status - when TaskStatus.fix_and_resubmit, TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.demonstrate - if to_same_day_anywhere_on_earth(due_date) < Time.zone.now + 7.days && can_apply_for_extension? && unit.extension_weeks_on_resubmit_request > 0 - grant_extension(assessor, unit.extension_weeks_on_resubmit_request) - end - end + # Grant an extension on fix if the deadline is close - see + # #grant_resubmission_extension for the rule and for why this only + # happens once per round of feedback + grant_resubmission_extension(task_status, assessor, assess_date) end # Save the task @@ -946,9 +1161,39 @@ def add_text_comment(user, text, reply_to_id = nil) comment.reply_to_id = reply_to_id comment.save! + notify_comment_recipient(comment) + comment end + # Tell the other party that a comment arrived. + # + # comment.recipient is already worked out above: the tutor when a student + # commented, the student when a tutor commented. Do not recalculate it. + # + # A project with no tutor for this task definition has no recipient, so the + # guard is required and not defensive padding. + # + # The comment text is deliberately not put in the notification. The email is a + # prompt to come back to OnTrack, not a copy of the conversation. + # + # Raising a notification must never stop a comment being posted, so failures + # are logged and swallowed. NotificationService already rescues mail errors; + # this catches the record write and anything else unexpected. + def notify_comment_recipient(comment) + return if comment.recipient.blank? + + NotificationService.notify( + user: comment.recipient, + type: 'feedback', + event: 'task_comment_created', + message: "#{comment.user.name} commented on #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}/feedback" + ) + rescue StandardError => e + logger.error "Failed to raise task_comment_created notification for task #{id}: #{e.message}" + end + def individual_task_or_submitter_of_group_task? return true if !group_task? # its individual return true if group.blank? # no group yet... so individual @@ -973,10 +1218,10 @@ def add_status_comment(current_user, status) def add_discussed_comment(current_user) comment = 'Discussed in class' - lc = comments.last - - # don't add if duplicate comment - return if lc && lc.user == current_user && lc.content_type == 'discussed_in_class' && lc.comment == comment + # This comment represents a boolean task state, so an intervening feedback + # comment must not allow a second marker to be created. + existing = comments.where(content_type: 'discussed_in_class').last + return existing if existing discussed = TaskDiscussedComment.create discussed.task = self @@ -987,6 +1232,14 @@ def add_discussed_comment(current_user) discussed end + # Undo a "discussed in class" mark by removing every marker on this task. + # Legacy data can contain duplicates separated by ordinary feedback comments. + # destroy_all is intentional so TaskComment callbacks and dependent read + # receipt destruction still run for every marker. + def remove_discussed_comment + comments.where(content_type: 'discussed_in_class').destroy_all + end + def add_checked_in_comment(current_user) discussed = TaskCheckedInComment.create discussed.task = self @@ -1014,11 +1267,34 @@ def add_discussion_comment(user, prompts) end discussion.mark_as_read(user, unit) + notify_discussion_request_recipient(discussion) logger.info(discussion) return discussion end + # EN-V08 was originally described as a discussion booking notification, but + # OnTrack has no booking or appointment record to hook. A discussion comment + # is the point where a tutor actually raises an audio prompt for a student, + # so notify the student once that prompt and its attachments are ready. + # + # Prompt content is deliberately left out of the notification and email. A + # notification failure must not stop the discussion comment being created. + def notify_discussion_request_recipient(discussion) + return if discussion.recipient.blank? + + NotificationService.notify( + user: discussion.recipient, + type: 'feedback', + event: 'discussion_request_created', + message: 'A discussion prompt is ready for you.', + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}/feedback" + ) + rescue StandardError => e + logger.error "Failed to raise discussion_request_created notification for task #{id}: #{e.message}" + end + private :notify_discussion_request_recipient + # TODO: Refactor to attachment comment (with inheritance on model) def add_comment_with_attachment(user, tempfile, reply_to_id = nil) ensured_group_submission if group_task? && group @@ -1155,6 +1431,7 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: raise "Multiple team member submissions received at the same time. Please ensure that only one member submits the task." if group_task? && self != group_submission.submitter_task zip_file = zip_file_path || zip_file_path_for_done_task + temp_zip = nil return false if zip_file.nil? || (!Dir.exist? task_dir) # compress image files - convert to jpg @@ -1178,14 +1455,17 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: logger.info "Creating new zip file for task #{id} in #{zip_file}" - # We have what looks like a good submission, remove old zip - FileUtils.rm_f(zip_file) - # copy all files into zip zip_dir = File.dirname(zip_file) FileUtils.mkdir_p zip_dir - Zip::File.open(zip_file, Zip::File::CREATE) do |zip| + # Build the new archive alongside the existing done zip and swap it in only + # once it has closed cleanly. Writing straight over zip_file, after removing + # it first, meant a failed add left the task with no readable submission at + # all, having already destroyed the previously accepted one. + temp_zip = "#{zip_file}.tmp-#{SecureRandom.hex(8)}" + + Zip::File.open(temp_zip, Zip::File::CREATE) do |zip| zip.mkdir id.to_s input_files.each do |in_file| final_name = in_file @@ -1198,8 +1478,25 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: zip.add "#{id}/#{final_name}", "#{task_dir}#{in_file}" end end + + # The archive is complete on disk, so it is now safe to swap it in. File.rename + # is an atomic same-directory replace and, unlike FileUtils.mv(force: true), + # raises if it fails instead of silently leaving the old zip in place while we + # go on to delete the source and report success. + File.rename(temp_zip, zip_file) + temp_zip = nil ensure - FileUtils.rm_rf(task_dir) if rm_task_dir + if temp_zip + # We entered the archive-write phase but did not swap the new zip in, so + # the write or the rename failed. Keep the source files in task_dir so the + # previously accepted submission can be recovered, and remove only the + # half-written temporary archive. + FileUtils.rm_f(temp_zip) + elsif rm_task_dir + # A clean success, an early rejection (missing files), or the group-guard + # raise: discard the source files as before. + FileUtils.rm_rf(task_dir) + end end true @@ -1833,9 +2130,17 @@ def delete_associated_files end end - # Use the current DateTime to calculate a new DateTime for the last moment of the same - # day anywhere on earth + # The last moment of the same day anywhere on earth. + # + # A deadline set as a day is not over until that day is over everywhere, which + # is 23:59:59 at UTC-12. Which day that is has to be read in the task's own + # zone, because a timestamp near midnight belongs to different calendar days + # in different zones. This used to read the day, month and year straight off + # the value as it happened to be loaded, so the answer moved by a whole day + # when a campus changed its offset for daylight saving. The result is built at + # a fixed -12:00 offset, which never observes daylight saving itself. def to_same_day_anywhere_on_earth(date) - DateTime.new(date.year, date.month, date.day, 23, 59, 59, '-12:00') + day = deadline_date(date) + Time.new(day.year, day.month, day.day, 23, 59, 59, '-12:00') end end diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 7ef377811f..d6d5c8318f 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -57,6 +57,11 @@ def self.permissions before_destroy :delete_associated_files + # The database default protects rows written by an older application process + # during a rolling deployment. Current code explicitly keeps new definitions + # untracked until a supported creation workflow has fully configured them. + before_create :defer_new_task_notifications + after_update :move_files_on_abbreviation_change, if: :saved_change_to_abbreviation? after_update :remove_old_group_submissions, if: :has_removed_group? after_update :check_and_update_tii_status, if: :saved_change_to_upload_requirements? @@ -70,7 +75,8 @@ def self.permissions belongs_to :tutorial_stream, optional: true belongs_to :overseer_image, optional: true - has_many :tasks, dependent: :destroy # Destroying a task definition will also nuke any instances + has_many :tasks, dependent: :destroy # Destroying a task definition will also nuke any instances + has_many :peer_progress_snapshots, dependent: :destroy, inverse_of: :task_definition has_many :group_submissions, dependent: :destroy # Destroying a task definition will also nuke any group submissions has_many :learning_outcomes, as: :context, dependent: :destroy has_many :overseer_steps, -> { order(:sort_order) }, inverse_of: :task_definition, dependent: :destroy @@ -133,6 +139,24 @@ def grade_start_date(target_grade) grade_due_dates.find { |g| g.target_grade == target_grade.to_i }&.start_date end + # Opt this fully configured definition into immediate and scheduled + # availability notifications. Existing definitions are backfilled from the + # rollout time by the migration; new supported workflows use the unit start + # so already-available copied/imported tasks can still be announced. + def enable_new_task_notifications! + notification_start = [unit.start_date, start_date].compact.min || Time.current + # Legacy definitions may fail unrelated validations; this internal marker + # must still be repairable by a retrying notification job. + # rubocop:disable Rails/SkipsModelValidations + update_column(:new_task_notifications_from, notification_start) + # rubocop:enable Rails/SkipsModelValidations + end + + def defer_new_task_notifications + self.new_task_notifications_from = nil + new_task_notifications_from_will_change! + end + def unit_must_be_same if unit.present? and tutorial_stream.present? and not unit.eql? tutorial_stream.unit errors.add(:unit, "should be same as the unit in the associated tutorial stream") @@ -174,6 +198,7 @@ def check_existing_prerequisites # Copy this task into the other unit def copy_to(other_unit) new_td = self.dup + new_td.new_task_notifications_from = nil # change the unit... new_td.unit_id = other_unit.id # for database diff --git a/app/models/test_attempt.rb b/app/models/test_attempt.rb index 9918414254..8ca8fb0bcb 100644 --- a/app/models/test_attempt.rb +++ b/app/models/test_attempt.rb @@ -59,6 +59,8 @@ def specific_permission_hash(role, perm_hash, _other) # fields that must be synced from cmi data whenever it's updated # t.boolean :completion_status, default: false + + # staff owned, and no longer synced from cmi data. See cmi_datamodel= below. # t.boolean :success_status, default: false # t.float :score_scaled, default: 0 @@ -96,10 +98,19 @@ def cmi_datamodel=(data) end # IMPORTANT: always sync any model attributes with cmi values here to ensure consistency! - # attributes derived from cmi keys: completion_status, success_status, score_scaled + # attributes derived from cmi keys: completion_status self.completion_status = new_data['cmi.completion_status'] == 'completed' - self.success_status = new_data['cmi.success_status'] == 'passed' - self.score_scaled = new_data['cmi.score.scaled'] + + # success_status and score_scaled are deliberately no longer derived here. + # The datamodel is posted by the scorm package running in the student's own + # browser, and this setter is only reachable through the :update_attempt arm + # of PATCH test_attempts/:id, which only students hold. Deriving the pass and + # the score from that blob let a student decide their own result, which is + # what the route already refuses when it is asked for directly. + # override_success_status is now the only writer of success_status and the + # route gates it on :override_success_status. Nothing writes score_scaled, so + # it keeps its 0.0 column default. The datamodel is still stored exactly as + # it was posted, so the package keeps its runtime state and can resume. write_attribute(:cmi_datamodel, new_data.to_json) end diff --git a/app/models/unit.rb b/app/models/unit.rb index 19e0098298..c0ccda3825 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -79,6 +79,7 @@ def self.permissions :upload_grades_csv, :get_staff_notes, :capture_task_completion_snapshot, + :run_similarity_scan, :mannage_communications, :delete_engagement ] @@ -108,6 +109,7 @@ def self.permissions :get_marking_sessions, :get_staff_notes, :get_tutor_times, + :run_similarity_scan, :mannage_communications, ] @@ -174,6 +176,7 @@ def role_for(user) has_many :learning_outcomes, as: :context, dependent: :destroy # inverse_of: :unit has_many :marking_sessions, dependent: :destroy has_many :task_completion_snapshots, dependent: :destroy, inverse_of: :unit + has_many :peer_progress_snapshots, dependent: :destroy, inverse_of: :unit has_many :communication_sets, class_name: 'CommunicationSet', dependent: :destroy has_many :communication_rules, through: :communication_sets, class_name: 'CommunicationRule' has_many :communication_set_schedules, through: :communication_sets, class_name: 'CommunicationSetSchedule' @@ -269,7 +272,15 @@ def saved_change_to_communication_schedule_inputs? end def ordered_task_definitions - task_definitions.order('start_date ASC, abbreviation ASC') + return task_definitions.order('start_date ASC, abbreviation ASC') unless task_definitions.loaded? + + task_definitions.sort_by do |task_definition| + [ + task_definition.start_date.nil? ? 0 : 1, + task_definition.start_date, + task_definition.abbreviation.to_s + ] + end end def convenors @@ -489,6 +500,7 @@ def autogen_date_within_unit_active_period def rollover(teaching_period, start_date, end_date, new_code) new_unit = self.dup + copied_task_definitions = [] new_unit.code = new_code if new_code.present? @@ -541,6 +553,7 @@ def rollover(teaching_period, start_date, end_date, new_code) # Duplicate task definitions task_definitions.each do |td| new_td = td.copy_to(new_unit) + copied_task_definitions << new_td td.learning_outcomes.each do |learning_outcome| # for each old task definition, duplicate the learning outcomes associated with it aswell new_outcome = learning_outcome.dup @@ -611,6 +624,8 @@ def rollover(teaching_period, start_date, end_date, new_code) end end + NewTaskAvailableNotificationJob.track_and_enqueue_all(copied_task_definitions) + new_unit end @@ -1633,7 +1648,9 @@ def import_student_groups_from_csv(group_set, file) project.enrol_in(grp.tutorial) end - grp.add_member(project) + # Bulk imports can add many students in one request. Do not send a + # separate notification for every CSV row. + grp.add_member(project, notify: false) success << { row: row, message: "Added #{username} to #{grp.name}." } rescue Exception => e @@ -1705,8 +1722,10 @@ def date_for_week_and_day(week, day) return nil if day_num.nil? start_day_num = start_date.wday + day_offset = day_num - start_day_num + day_offset += 7 if day_offset.negative? - start_date + (week - 1).weeks + (day_num - start_day_num).days + start_date + (week - 1).weeks + day_offset.days end end @@ -1722,10 +1741,11 @@ def week_number(date) end end - def import_tasks_from_csv(file) + def import_tasks_from_csv(file, notify: true) success = [] errors = [] ignored = [] + imported_task_definitions = [] data = read_file_to_str(file) @@ -1756,6 +1776,7 @@ def import_tasks_from_csv(file) end prerequisites_by_task[task_definition.abbreviation] = JSON.parse(row[:task_prerequisites]) unless row[:task_prerequisites].nil? + imported_task_definitions << task_definition if new_task success << { row: row, message: message } rescue Exception => e @@ -1797,6 +1818,10 @@ def import_tasks_from_csv(file) end end + if notify + NewTaskAvailableNotificationJob.track_and_enqueue_all(imported_task_definitions) + end + { success: success, ignored: ignored, diff --git a/app/models/user.rb b/app/models/user.rb index 159b9aab7f..9cc7f8653e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,6 +19,7 @@ class User < ApplicationRecord include UserTiiModule + before_save :stamp_theme_preference_updated_at, if: :will_save_change_to_theme_preference? after_update :move_files_on_username_change, if: :saved_change_to_username? ### @@ -157,6 +158,7 @@ def token_for_text?(a_token, token_type) has_many :engagements, dependent: :restrict_with_exception, inverse_of: :user has_many :engagement_comments, dependent: :restrict_with_exception, inverse_of: :user has_many :auth_tokens, dependent: :destroy, inverse_of: :user + has_many :consumed_lti_tokens, dependent: :destroy, inverse_of: :user has_many :user_oauth_tokens, dependent: :destroy, inverse_of: :user has_many :user_oauth_states, dependent: :destroy, inverse_of: :user has_one :webcal, dependent: :destroy, inverse_of: :user @@ -164,6 +166,10 @@ def token_for_text?(a_token, token_type) has_many :marking_sessions, dependent: :destroy + # Notifications feature + has_many :notifications, dependent: :destroy, inverse_of: :user + has_many :push_subscriptions, dependent: :destroy, inverse_of: :user + # Model validations/constraints validates :first_name, presence: true validates :last_name, presence: true @@ -171,6 +177,7 @@ def token_for_text?(a_token, token_type) validates :username, presence: true, uniqueness: { case_sensitive: false } validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i } validates :student_id, uniqueness: true, allow_nil: true + validates :theme_preference, inclusion: { in: %w[light dark system] }, allow_nil: true validate :can_change_to_role?, if: :will_save_change_to_role_id? # Queries @@ -607,4 +614,10 @@ def get_marking_sessions(unit, start_date: nil, end_date: nil, timezone: nil) unit_role.get_marking_sessions(start_date: start_date, end_date: end_date, timezone: timezone) end end + + private + + def stamp_theme_preference_updated_at + self.theme_preference_updated_at = theme_preference.nil? ? nil : Time.current + end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb new file mode 100644 index 0000000000..0e7b9d4f6c --- /dev/null +++ b/app/services/notification_service.rb @@ -0,0 +1,128 @@ +# Central entry point for raising a notification. +# +# Creates the in-app record and fans out to the enabled delivery channels +# (email and push through Sidekiq). A single category toggle (the user's +# receive_*_notifications preference) gates every channel: if the category is +# off, the notification is suppressed entirely. Per-channel granularity +# (a type x channel matrix) is deferred to a future iteration. +# +# Usage: +# NotificationService.notify( +# user: project.student, +# type: 'feedback', +# event: 'task_comment_created', +# message: "New feedback is ready for #{task_definition.name}.", +# link: "/#/projects/#{project.id}" +# ) +class NotificationService + # Raise a notification for a user. Returns the created Notification, or nil if + # the user's preference suppresses this category. + # + # type - the category the user's preference switches on, one of + # Notification::TYPES. + # event - the specific thing that happened, e.g. 'task_comment_created'. + # Required, so every notification can be traced back to its source. + def self.notify(user:, type:, event:, message:, link: nil, dedupe_key: nil) + notification = reserve( + user: user, + type: type, + event: event, + message: message, + link: link, + dedupe_key: dedupe_key + ) + + deliver(notification) + end + + # Persist a notification without running its delivery channels. Callers that + # need a short eligibility lock can commit this reservation, release the + # lock, and then call `deliver` without holding a row lock across network I/O. + def self.reserve(user:, type:, event:, message:, link: nil, dedupe_key: nil) + type = type.to_s + return nil unless deliver_to?(user, type) + + create_notification( + user: user, + notification_type: type, + event: event.to_s, + message: message, + link: link, + dedupe_key: dedupe_key + ) + end + + def self.deliver(notification) + return nil if notification.nil? + + # Concurrent or retried fan-outs can reserve the same immutable event. A + # lock on that notification (not on the student's project) serializes only + # its push hand-off. Email is queued once by Notification's after_commit + # hook, so it cannot be consumed before an enclosing transaction commits + # and a dedupe retry cannot queue it twice. delivered_at tracks the async + # push hand-off; a failed hand-off stays retryable. + notification.with_lock do + unless notification.delivered_at? + push_queued = queue_push(notification) + notification.update!(delivered_at: Time.current) if push_queued + end + end + + notification + end + + # Whether the user's category preference allows this notification type. + def self.deliver_to?(user, type) + pref = Notification::PREFERENCE_FOR_TYPE[type.to_s] + return true if pref.nil? # types without a preference are always sent + + user.public_send(pref) + end + + # A non-null dedupe key is an immutable event identity. The unique database + # index makes concurrent fan-out jobs race safely: exactly one insert wins, + # and only that winner runs the after_commit hook that queues the email. + def self.create_notification(**attributes) + Notification.transaction(requires_new: true) do + Notification.create!(**attributes) + end + rescue ActiveRecord::RecordNotUnique + raise if attributes[:dedupe_key].blank? + + Notification.find_by!( + user: attributes.fetch(:user), + dedupe_key: attributes.fetch(:dedupe_key) + ) + end + private_class_method :create_notification + + # Email channel. Called from Notification's after_commit hook, never directly + # from notify or deliver, so the notification is committed before the job exists. + # + # Queue only the stable Notification id; message content, recipient details + # and other student data remain in the database. Queue connection errors are + # best-effort so the in-app record and push delivery are not blocked. Delivery + # failures are raised by the job for Sidekiq to retry. + def self.queue_email(notification) + NotificationEmailJob.perform_async(notification.id) + rescue StandardError => e + Rails.logger.error( + "Failed to queue notification email for Notification #{notification.id}: #{e.class}" + ) + false + end + + # Push channel. Queue only the stable Notification id so no student or + # notification content is copied into Redis. A failed hand-off leaves + # delivered_at unset, allowing the existing availability retry to try the + # push hand-off again without duplicating the after-commit email. + def self.queue_push(notification) + PushNotificationDeliveryJob.perform_async(notification.id) + rescue StandardError => e + Rails.logger.error( + "Failed to queue notification push for Notification #{notification.id}: #{e.class}" + ) + false + end + private_class_method :queue_push +end diff --git a/app/services/peer_progress_aggregation_service.rb b/app/services/peer_progress_aggregation_service.rb new file mode 100644 index 0000000000..6ad693f5c7 --- /dev/null +++ b/app/services/peer_progress_aggregation_service.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# Calculates and stores task-level peer-progress snapshots for one unit. +# +# This service stores aggregate values only. It does not authorise students, +# apply the small-cohort display threshold, or expose API response data. +class PeerProgressAggregationService + class UnsupportedTaskStatusError < StandardError; end + + def self.call(unit:, calculated_at: Time.zone.now) + new(unit: unit, calculated_at: calculated_at).call + end + + def initialize(unit:, calculated_at:) + unless unit.is_a?(Unit) && unit.persisted? + raise ArgumentError, 'unit must be a persisted Unit' + end + raise ArgumentError, 'calculated_at is required' if calculated_at.blank? + + @unit = unit + @calculated_at = calculated_at + end + + def call + snapshots = [] + + PeerProgressSnapshot.transaction do + existing_snapshots = PeerProgressSnapshot.where(unit: unit).index_by do |snapshot| + [snapshot.task_definition_id, snapshot.target_grade] + end + + unit.grade_values.map(&:to_i).uniq.sort.each do |target_grade| + cohort = unit.active_projects.where(target_grade: target_grade) + cohort_size = cohort.count + + task_definitions = unit.task_definitions + .where('target_grade <= ?', target_grade) + .order(:id) + + submitted_counts = submitted_counts_for( + cohort: cohort, + task_definitions: task_definitions + ) + status_counts = status_counts_for( + cohort: cohort, + task_definitions: task_definitions, + cohort_size: cohort_size + ) + + task_definitions.each do |task_definition| + key = [task_definition.id, target_grade] + submitted_count = submitted_counts.fetch(task_definition.id, 0) + + snapshot = existing_snapshots[key] || PeerProgressSnapshot.new( + unit: unit, + task_definition: task_definition, + target_grade: target_grade + ) + + snapshot.assign_attributes( + cohort_size: cohort_size, + submitted_count: submitted_count, + submitted_percentage: percentage( + submitted_count: submitted_count, + cohort_size: cohort_size + ), + status_counts: status_counts.fetch(task_definition.id), + calculated_at: calculated_at + ) + + snapshot.save! + snapshots << snapshot + end + end + end + + snapshots + end + + private + + attr_reader :unit, :calculated_at + + def submitted_counts_for(cohort:, task_definitions:) + Task + .where( + project_id: cohort.select(:id), + task_definition_id: task_definitions.select(:id) + ) + .where.not(file_uploaded_at: nil) + .group(:task_definition_id) + .distinct + .count(:project_id) + end + + def status_counts_for(cohort:, task_definitions:, cohort_size:) + materialized_counts = Task + .where( + project_id: cohort.select(:id), + task_definition_id: task_definitions.select(:id) + ) + .group(:task_definition_id, :task_status_id) + .distinct + .count(:project_id) + + task_definitions.to_h do |task_definition| + counts = PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 0 } + + materialized_counts.each do |(task_definition_id, status_id), count| + next unless task_definition_id == task_definition.id + + status = canonical_status_for(status_id) + counts[status] += count + end + + missing_task_count = cohort_size - counts.values.sum + if missing_task_count.negative? + raise ArgumentError, + 'task status counts exceed the peer-progress cohort size' + end + + counts['not_started'] += missing_task_count + [task_definition.id, counts] + end + end + + def canonical_status_for(status_id) + id = status_id.to_i + expected_status = PeerProgressDistributionPolicy::STATUS_KEYS[id - 1] + mapped_status = TaskStatus.id_to_key(id).to_s if expected_status.present? + + return mapped_status if expected_status.present? && + mapped_status == expected_status + + # TaskStatus.id_to_key deliberately falls back to not_started for unknown + # IDs. That is useful elsewhere, but would silently corrupt an aggregate + # if a new lifecycle state were introduced without extending this policy. + raise UnsupportedTaskStatusError, + 'peer-progress aggregation encountered an unsupported task status' + end + + def percentage(submitted_count:, cohort_size:) + return nil if cohort_size.zero? + + ((submitted_count * 100.0) / cohort_size).round(2) + end +end diff --git a/app/services/peer_progress_distribution_policy.rb b/app/services/peer_progress_distribution_policy.rb new file mode 100644 index 0000000000..75786e2df1 --- /dev/null +++ b/app/services/peer_progress_distribution_policy.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# Builds the public, privacy-preserving task-status distribution from an +# internal peer-progress snapshot. +# +# Quantising every status independently is not sufficient on its own. When the +# buckets are considered together, their sum constraint can occasionally make +# a raw count unique (for example, a cohort of 24 split into 6 and 18). Before +# releasing a vector, this policy assumes an observer knows the cohort size and +# verifies that every status still has at least two feasible raw counts. +class PeerProgressDistributionPolicy + PERCENTAGE_BUCKET_SIZE = 10.0 + + STATUS_KEYS = %w[ + not_started + complete + need_help + working_on_it + fix_and_resubmit + feedback_exceeded + redo + discuss + ready_for_feedback + demonstrate + fail + time_exceeded + assess_in_portfolio + attention_required + rediscuss + ].freeze + + def self.quantised_percentage(value) + ((value.to_f / PERCENTAGE_BUCKET_SIZE).round * + PERCENTAGE_BUCKET_SIZE).to_f + end + + def self.percentage(count:, cohort_size:) + return nil unless cohort_size.to_i.positive? + + ((count.to_i * 100.0) / cohort_size).round(2) + end + + def self.quantised_count_percentage(count:, cohort_size:) + quantised_percentage( + percentage(count: count, cohort_size: cohort_size) + ) + end + + def self.build(status_counts:, cohort_size:) + counts = normalized_counts(status_counts) + return nil if counts.nil? || cohort_size.to_i <= 0 + return nil unless counts.values.sum == cohort_size + + distribution = STATUS_KEYS.map do |status| + { + status: status, + percentage: quantised_count_percentage( + count: counts.fetch(status), + cohort_size: cohort_size + ) + } + end + + return nil unless preserves_count_ambiguity?( + distribution: distribution, + cohort_size: cohort_size + ) + + distribution + end + + def self.valid_status_counts?(status_counts, cohort_size:) + counts = normalized_counts(status_counts) + + counts.present? && counts.values.sum == cohort_size + end + + def self.normalized_counts(status_counts) + return nil unless status_counts.is_a?(Hash) + + counts = status_counts.transform_keys(&:to_s) + return nil unless counts.keys.sort == STATUS_KEYS.sort + return nil unless counts.values.all? do |value| + value.is_a?(Integer) && value >= 0 + end + + counts + end + private_class_method :normalized_counts + + def self.preserves_count_ambiguity?(distribution:, cohort_size:) + ranges = distribution.map do |entry| + count_range_for_bucket( + entry.fetch(:percentage), + cohort_size + ) + end + + minimum_sum = ranges.sum(&:begin) + maximum_sum = ranges.sum(&:end) + + ranges.all? do |range| + other_minimum = minimum_sum - range.begin + other_maximum = maximum_sum - range.end + feasible_minimum = [range.begin, cohort_size - other_maximum].max + feasible_maximum = [range.end, cohort_size - other_minimum].min + + feasible_maximum - feasible_minimum >= 1 + end + end + private_class_method :preserves_count_ambiguity? + + # The quantised value is monotonic as count increases. Binary-searching both + # edges avoids rebuilding every possible count bucket on every student GET: + # detailed policy evaluation is O(statuses * log(cohort_size)), with no + # unbounded cohort-size cache. + def self.count_range_for_bucket(bucket, cohort_size) + first = binary_search_count(cohort_size) do |count| + quantised_count_percentage( + count: count, + cohort_size: cohort_size + ) >= bucket + end + last = binary_search_count(cohort_size, upper: true) do |count| + quantised_count_percentage( + count: count, + cohort_size: cohort_size + ) <= bucket + end + + first..last + end + private_class_method :count_range_for_bucket + + def self.binary_search_count(cohort_size, upper: false) + low = 0 + high = cohort_size + + while low < high + midpoint = (low + high + (upper ? 1 : 0)) / 2 + if yield(midpoint) + upper ? low = midpoint : high = midpoint + else + upper ? high = midpoint - 1 : low = midpoint + 1 + end + end + + low + end + private_class_method :binary_search_count +end diff --git a/app/services/peer_progress_viewer_policy.rb b/app/services/peer_progress_viewer_policy.rb new file mode 100644 index 0000000000..40b0f011f2 --- /dev/null +++ b/app/services/peer_progress_viewer_policy.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Converts an internal whole-cohort snapshot into peer-only exact aggregates +# for one authenticated viewer. Public quantisation and vector ambiguity checks +# are applied afterwards; raw values from this policy never cross the API. +class PeerProgressViewerPolicy + def self.viewer_context_current?(snapshot:, viewer_project:, viewer_task:) + project_current = viewer_project.persisted? && + viewer_project.updated_at.present? && + viewer_project.updated_at <= snapshot.calculated_at + task_current = !viewer_task.persisted? || + (viewer_task.updated_at.present? && + viewer_task.updated_at <= snapshot.calculated_at) + + project_current && task_current + end + + def self.build(snapshot:, viewer_project:, viewer_task:) + return nil unless viewer_context_current?( + snapshot: snapshot, + viewer_project: viewer_project, + viewer_task: viewer_task + ) + return nil unless snapshot.submitted_count.is_a?(Integer) + return nil unless snapshot.submitted_count.between?( + 0, + snapshot.cohort_size + ) + return nil unless PeerProgressDistributionPolicy.valid_status_counts?( + snapshot.status_counts, + cohort_size: snapshot.cohort_size + ) + + peer_cohort_size = snapshot.cohort_size - 1 + return nil if peer_cohort_size.negative? + + counts = snapshot.status_counts.to_h.transform_keys(&:to_s).dup + viewer_status = canonical_status(viewer_task.task_status_id) + return nil if viewer_status.nil? || counts.fetch(viewer_status).zero? + + counts[viewer_status] -= 1 + submitted_count = snapshot.submitted_count + submitted_count -= 1 if viewer_task.file_uploaded_at.present? + return nil unless submitted_count.between?(0, peer_cohort_size) + return nil unless PeerProgressDistributionPolicy.valid_status_counts?( + counts, + cohort_size: peer_cohort_size + ) + + { + cohort_size: peer_cohort_size, + submitted_count: submitted_count, + status_counts: counts + } + end + + def self.public_metrics(peer_progress) + counts = peer_progress.fetch(:status_counts) + cohort_size = peer_progress.fetch(:cohort_size) + distribution = PeerProgressDistributionPolicy.build( + status_counts: counts, + cohort_size: cohort_size + ) + + { + submitted_percentage: + PeerProgressDistributionPolicy.quantised_count_percentage( + count: peer_progress.fetch(:submitted_count), + cohort_size: cohort_size + ), + completed_percentage: + PeerProgressDistributionPolicy.quantised_count_percentage( + count: counts.fetch('complete'), + cohort_size: cohort_size + ), + status_distribution: distribution, + distribution_unavailable_reason: + distribution.nil? ? 'privacy_protection' : nil + } + end + + def self.canonical_status(status_id) + id = status_id.to_i + expected_status = PeerProgressDistributionPolicy::STATUS_KEYS[id - 1] + mapped_status = TaskStatus.id_to_key(id).to_s if expected_status.present? + + mapped_status if mapped_status == expected_status + end + private_class_method :canonical_status +end diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb new file mode 100644 index 0000000000..54dc63d4ee --- /dev/null +++ b/app/services/push_notification_service.rb @@ -0,0 +1,220 @@ +# Web Push delivery channel. +# +# PushNotificationDeliveryJob calls this for every notification handed off by +# NotificationService. Two properties make that safe: +# +# * without VAPID keys it is a no-op, so the app behaves exactly as it did +# before push existed for anyone who has not configured them +# * one browser failing never stops attempts to the others; transient failures +# are raised only after fan-out so Sidekiq can retry the delivery job +# +# Because the shared fan-out queues the job, every event that queues an email +# also queues a push, with no per-event work. +# +# Key generation and setup: docs/notifications/push-setup.md. +class PushNotificationService + class DeliveryError < StandardError; end + + # Push services reject a payload much over 4KB once encrypted. Nothing here + # comes close, but the message is user-facing text assembled from names and + # task titles, so it is trimmed rather than trusted. + MAX_BODY_LENGTH = 400 + + # Lock-screen copy is a separate channel from the richer in-app notification + # and email. These v2 events contain free-form names or precise schedule data + # in Notification#message, so use bounded, reviewed copy whenever the payload + # is rendered. Keeping the decision here means direct delivery and any future + # payload regeneration cannot accidentally bypass the privacy boundary. + LOCK_SCREEN_BODY_OVERRIDES = { + 'tutorial_changed' => 'Your tutorial details changed.', + 'group_membership_changed' => 'Your group membership changed.', + 'task_submitted' => 'A task is ready for marking.', + 'portfolio_received' => 'Your portfolio submission was received.' + }.freeze + + # MN-C03 BEGIN: safe click route constants + SAFE_CLICK_FALLBACK = '/notifications'.freeze + MAX_CLICK_LINK_LENGTH = 256 + FORBIDDEN_CLICK_LINK_TEXT = /[\u0000-\u001f\u007f\s\\?#%]/ + SAFE_PROJECT_ROOT_LINK = %r{\A/projects/[1-9]\d*/(?:dashboard|groups)\z} + SAFE_PROJECT_TASK_LINK = %r{\A/projects/[1-9]\d*/dashboard/[A-Za-z0-9][A-Za-z0-9._-]{0,31}(?:/feedback)?\z}x + # MN-C03 END: safe click route constants + # Seconds. web-push sets no timeouts of its own, so without these a push + # service that accepts a connection and then never answers holds a Sidekiq + # worker thread and reduces delivery capacity until the process kills it. + # + # Both are passed together on purpose. web-push 3.0.1 guards read_timeout on + # open_timeout being present (lib/web_push/request.rb line 15), so passing + # read_timeout alone silently does nothing. + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 5 + SSL_TIMEOUT = 5 + + # Push services otherwise retain messages for the gem default of four weeks. + # OnTrack notifications describe current workflow state, so delivering one + # days or weeks later is misleading. One hour tolerates a short disconnect + # without surfacing stale due-date or status alerts. + MESSAGE_TTL = 1.hour.to_i + MESSAGE_URGENCY = 'normal'.freeze + + def self.deliver(notification) + return unless configured? + + subscriptions = notification.user.push_subscriptions.to_a + return if subscriptions.empty? + + payload = payload_for(notification) + + failures = [] + subscriptions.each do |subscription| + deliver_to(subscription, payload) + rescue StandardError => e + # Finish the fan-out before raising. This preserves delivery to healthy + # browsers while ensuring a provider outage reaches Sidekiq's retry path. + Rails.logger.error "Failed to push to subscription #{subscription.id}: #{e.class}" + failures << e + end + + return if failures.empty? + + raise DeliveryError, + "Push delivery failed for #{failures.length} subscription(s)", + cause: failures.first + end + + # The shape Angular's own ngsw-worker.js understands. It looks for a top level + # "notification" key and displays the notification itself, which is why none of + # this needs a hand written service worker. Use any other shape and somebody + # has to write one. + # + # data.link is what MN-C03 reads to decide where to send the user on click. + # + # tag and renotify are both in the service worker's own list of forwarded + # option names (ngsw-worker.js, NOTIFICATION_OPTION_NAMES), so they reach + # showNotification without any change on the web side. + def self.payload_for(notification) + click_link = safe_click_link(notification.link) + + { + notification: { + title: Doubtfire::Application.config.institution[:product_name], + body: body_for(notification), + tag: tag_for(notification), + icon: '/assets/icons/android-chrome-192x192.png', + badge: '/assets/icons/android-chrome-192x192.png', + # False, so a replacement updates the banner without making a sound or + # vibrating again. + # + # A burst is the case this exists for: a tutor working through one task + # posts five comments in two minutes. The tag already collapses those + # into one banner, and renotify: true would put the buzz back on every + # one of them, which is most of what made the burst worth collapsing. + # The user has already been interrupted once and the banner is already + # on their screen saying the newest thing. + # + # The cost is that a genuinely new message inside an ongoing + # conversation arrives silently while the old banner is still up. That + # is the right way round: the person has been told, and the alternative + # is being told five times. + renotify: false, + data: { + notification_id: notification.id, + link: click_link, + onActionClick: { + default: { + operation: 'focusLastFocusedOrOpen', + url: click_link + } + } + } + } + }.to_json + end + + def self.safe_click_link(link) + return SAFE_CLICK_FALLBACK unless link.is_a?(String) + return SAFE_CLICK_FALLBACK if link.empty? || link.length > MAX_CLICK_LINK_LENGTH + return SAFE_CLICK_FALLBACK unless link == link.strip + return SAFE_CLICK_FALLBACK if link.match?(FORBIDDEN_CLICK_LINK_TEXT) + return link if link == SAFE_CLICK_FALLBACK || link.match?(SAFE_PROJECT_ROOT_LINK) + return link if link.match?(SAFE_PROJECT_TASK_LINK) + + SAFE_CLICK_FALLBACK + end + + def self.body_for(notification) + LOCK_SCREEN_BODY_OVERRIDES + .fetch(notification.event.to_s, notification.message.to_s) + .truncate(MAX_BODY_LENGTH) + end + + # Use the event and validated destination as the collapse key so repeated + # pushes about the same event and task can replace one banner. + # + # notification_type is intentionally not used because several different + # events share one category. For example, a task status change must not + # silently replace a due-date warning about the same task. + # + # A missing or rejected destination receives a notification-specific tag. + # This prevents unrelated downgraded notifications from replacing each other + # and prevents the rejected raw route from being copied into the tag. + def self.tag_for(notification) + click_link = safe_click_link(notification.link) + return "notification-#{notification.id}" unless click_link == notification.link + + "#{notification.event}:#{click_link}" + end + + def self.deliver_to(subscription, payload) + # Checked again here rather than trusted from the row. PushSubscription + # validates this on write, but rows created before that validation existed + # were never checked, and this is the line that actually makes the outbound + # request. Refusing here is what stops a stored bad endpoint being used. + unless PushSubscription.push_service_endpoint?(subscription.endpoint) + Rails.logger.error "Refusing to push to subscription #{subscription.id}: endpoint is not a recognised push service" + return + end + + WebPush.payload_send( + message: payload, + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + vapid: vapid_details, + ttl: MESSAGE_TTL, + urgency: MESSAGE_URGENCY, + open_timeout: OPEN_TIMEOUT, + read_timeout: READ_TIMEOUT, + ssl_timeout: SSL_TIMEOUT + ) + rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription => e + # 410 or 404. The browser has thrown this registration away, or it was never + # valid. Nothing will ever reach it again, so drop the row rather than retry + # it on every notification from here to the end of time. + Rails.logger.info "Removing dead push subscription #{subscription.id}: #{e.class}" + subscription.destroy + end + + def self.vapid_details + { + subject: vapid_subject, + public_key: ENV.fetch('DOUBTFIRE_VAPID_PUBLIC_KEY'), + private_key: ENV.fetch('DOUBTFIRE_VAPID_PRIVATE_KEY') + } + end + + # Push services want a way to contact whoever is sending, as a mailto: or a + # URL. They reject the request outright if it is missing. + def self.vapid_subject + ENV['DOUBTFIRE_VAPID_SUBJECT'].presence || + Doubtfire::Application.config.institution[:host].presence || + 'mailto:noreply@doubtfire.local' + end + + # True once VAPID keys are configured. Keeps the fan-out safe to call today. + def self.configured? + ENV['DOUBTFIRE_VAPID_PUBLIC_KEY'].present? && ENV['DOUBTFIRE_VAPID_PRIVATE_KEY'].present? + end + + private_class_method :body_for, :safe_click_link, :deliver_to, :vapid_details, :vapid_subject +end diff --git a/app/services/readiness_check.rb b/app/services/readiness_check.rb new file mode 100644 index 0000000000..6d25f41251 --- /dev/null +++ b/app/services/readiness_check.rb @@ -0,0 +1,28 @@ +class ReadinessCheck + DATABASE_QUERY = 'SELECT 1'.freeze + + def initialize(database_connection_pool: ActiveRecord::Base.connection_pool, redis: Sidekiq) + @database_connection_pool = database_connection_pool + @redis = redis + end + + def ready? + database_ready? && redis_ready? + rescue StandardError + false + end + + private + + def database_ready? + result = @database_connection_pool.with_connection do |connection| + connection.select_value(DATABASE_QUERY) + end + + result.to_s == '1' + end + + def redis_ready? + @redis.redis(&:ping) == 'PONG' + end +end diff --git a/app/services/task_prioritization_service.rb b/app/services/task_prioritization_service.rb new file mode 100644 index 0000000000..b4b7a7fe08 --- /dev/null +++ b/app/services/task_prioritization_service.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +class TaskPrioritizationService + Candidate = Data.define(:project, :task_definition, :task, :due_date, :blocked) + + DEADLINE_HORIZON_DAYS = 28 + DEADLINE_WEIGHT = 0.60 + WORKLOAD_WEIGHT = 0.25 + TASK_SIZE_WEIGHT = 0.15 + WORKLOAD_MIDPOINT = 5.0 + PREREQUISITE_STATUS_LEVELS = { + attention_required: 0, + ready_for_feedback: 1, + assess_in_portfolio: 1, + discuss: 2, + rediscuss: 2, + demonstrate: 2, + complete: 3 + }.freeze + + def initialize(user, today: Time.zone.today) + @user = user + @today = today + end + + def call + candidates = remaining_candidates + recommendation_candidates = candidates.reject(&:blocked) + task_size_scores = calculate_task_size_scores(candidates) + workload_scores = calculate_workload_scores(candidates, task_size_scores) + + recommendations = recommendation_candidates.map do |candidate| + [candidate, build_recommendation(candidate, task_size_scores, workload_scores)] + end + sorted_recommendations = recommendations.sort_by do |candidate, recommendation| + [ + -recommendation[:priority_score], + candidate.due_date || Date.new(9999, 12, 31), + recommendation[:project_id], + recommendation[:task_definition_id] + ] + end + + sorted_recommendations.map(&:last) + end + + private + + attr_reader :today, :user + + def remaining_candidates + projects.flat_map do |project| + tasks_by_definition = project.tasks.index_by(&:task_definition_id) + + assigned_task_definitions(project).filter_map do |task_definition| + task = tasks_by_definition[task_definition.id] + next if task && final_status_ids.include?(task.task_status_id) + + Candidate.new( + project: project, + task_definition: task_definition, + task: task, + due_date: effective_due_date(project, task_definition, task)&.to_date, + blocked: blocked_by_prerequisite?(task_definition, tasks_by_definition) + ) + end + end + end + + def projects + Project + .for_user(user, false) + .includes( + { tasks: [:task_status, { task_definition: :grade_due_dates }] }, + { unit: { task_definitions: [:grade_due_dates, :task_prerequisites] } } + ) + end + + def assigned_task_definitions(project) + @assigned_task_definitions ||= {} + @assigned_task_definitions[project.id] ||= project.unit.task_definitions.select do |task_definition| + task_definition.target_grade <= project.target_grade.to_i + end + end + + def final_status_ids + @final_status_ids ||= [ + TaskStatus.complete.id, + TaskStatus.fail.id, + TaskStatus.feedback_exceeded.id, + TaskStatus.time_exceeded.id, + TaskStatus.assess_in_portfolio.id, + TaskStatus.ready_for_feedback.id + ] + end + + def effective_due_date(project, task_definition, task) + return task.local_due_date if task + + if project.unit.allow_flexible_dates + grade_target_date = task_definition.grade_target_date(project.target_grade.to_i) + return grade_target_date if grade_target_date + end + + task_definition.target_date + end + + def blocked_by_prerequisite?(task_definition, tasks_by_definition) + task_definition.task_prerequisites.any? do |link| + prerequisite_task = tasks_by_definition[link.prerequisite_id] + next true unless prerequisite_task&.ready_or_complete? + + current_level = PREREQUISITE_STATUS_LEVELS[prerequisite_task.status] + required_level = PREREQUISITE_STATUS_LEVELS[TaskStatus.id_to_key(link.task_status_id)] + + current_level.nil? || required_level.nil? || current_level < required_level + end + end + + # Weighting is comparable within a unit, not across units. The denominator + # includes all work assigned at the student's target grade, so completing a + # task does not inflate the relative size of every task that remains. + def calculate_task_size_scores(candidates) + project_totals = candidates.map(&:project).uniq.to_h do |project| + assigned_definitions = assigned_task_definitions(project) + total_weight = assigned_definitions.sum { |task_definition| definition_weight(task_definition) } + + [project.id, { weight: total_weight, count: assigned_definitions.length }] + end + + candidates.to_h do |candidate| + totals = project_totals.fetch(candidate.project.id) + score = if totals[:weight].positive? + (task_weight(candidate) / totals[:weight]) * 100 + elsif totals[:count].positive? + 100.0 / totals[:count] + else + 0 + end + [candidate, score] + end + end + + # Workload pressure is full-project percentage points due by this task's date + # per available day. A fixed saturating curve maps five percentage points per + # day to 50 without rescaling recommendations against one another. + # Grouping equal dates before accumulating preserves the inclusive + # "work due by this date" semantics without rescanning every candidate. + def calculate_workload_scores(candidates, task_size_scores) + workload_scores = candidates.index_with { 0 } + candidates_with_due_dates = candidates.select(&:due_date).group_by(&:due_date) + cumulative_work = 0.0 + + candidates_with_due_dates.sort_by { |due_date, _| due_date }.each do |due_date, due_candidates| + cumulative_work += due_candidates.sum { |candidate| task_size_scores.fetch(candidate) } + available_days = [(due_date - today).to_i, 1].max + raw_pressure = cumulative_work / available_days + pressure = (raw_pressure * 100) / (raw_pressure + WORKLOAD_MIDPOINT) + + due_candidates.each do |candidate| + workload_scores[candidate] = pressure + end + end + + workload_scores + end + + def task_weight(candidate) + definition_weight(candidate.task_definition) + end + + def definition_weight(task_definition) + [task_definition.weighting.to_f, 0].max + end + + def deadline_score(candidate) + return 0 unless candidate.due_date + + days_left = (candidate.due_date - today).to_i + return 100 if days_left <= 0 + return 0 if days_left >= DEADLINE_HORIZON_DAYS + + ((DEADLINE_HORIZON_DAYS - days_left) / DEADLINE_HORIZON_DAYS.to_f) * 100 + end + + def build_recommendation(candidate, task_size_scores, workload_scores) + priority_score = + (DEADLINE_WEIGHT * deadline_score(candidate)) + + (WORKLOAD_WEIGHT * workload_scores.fetch(candidate)) + + (TASK_SIZE_WEIGHT * task_size_scores.fetch(candidate)) + priority_score = priority_score.clamp(0, 100) + + { + task_id: candidate.task&.id, + task_definition_id: candidate.task_definition.id, + task_name: candidate.task_definition.name, + project_id: candidate.project.id, + unit_id: candidate.project.unit_id, + priority_score: priority_score.round(2) + } + end +end diff --git a/app/sidekiq/aggregate_peer_progress_job.rb b/app/sidekiq/aggregate_peer_progress_job.rb new file mode 100644 index 0000000000..c320e7e94a --- /dev/null +++ b/app/sidekiq/aggregate_peer_progress_job.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class AggregatePeerProgressJob + class AggregationError < StandardError; end + + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options lock: :until_executed, + lock_args_method: lambda { |args| + [args.first || 'all-active-units'] + }, + on_conflict: :reject, + retry: 3 + + def perform(unit_id = nil) + return enqueue_active_units if unit_id.blank? + + aggregate_unit(Unit.find(unit_id)) + rescue StandardError => e + log_unit_id = unit_id.presence || 'all-active-units' + failure_message = + "Peer progress aggregation failed for unit_id=#{log_unit_id}: " \ + "#{e.class.name}" + + logger.error(failure_message) + raise AggregationError, failure_message, cause: nil + end + + private + + def enqueue_active_units + logger.info( + 'Queueing peer progress aggregation for active units...' + ) + + # Only units whose convenor has opted in. Aggregating the rest would store + # derived cohort statistics for units that never enabled the feature, and + # the endpoint returns early on peer_progress_enabled? so those rows could + # never be served anyway. + Unit.active_units.where(peer_progress_enabled: true).find_each do |unit| + self.class.perform_async(unit.id) + end + + logger.info( + 'Queued peer progress aggregation jobs.' + ) + end + + def aggregate_unit(unit) + unless unit.active? + logger.info( + "Skipping peer progress aggregation for inactive unit_id=#{unit.id}" + ) + return + end + + unless unit.peer_progress_enabled? + logger.info( + "Skipping peer progress aggregation for unit_id=#{unit.id}, " \ + 'peer progress is not enabled' + ) + return + end + + logger.info( + "Starting peer progress aggregation for unit_id=#{unit.id}..." + ) + + at(0) + total(1) + + PeerProgressAggregationService.call( + unit: unit, + calculated_at: Time.zone.now + ) + + at(1) + + logger.info( + "Completed peer progress aggregation for unit_id=#{unit.id}." + ) + end +end diff --git a/app/sidekiq/check_unit_similarity_job.rb b/app/sidekiq/check_unit_similarity_job.rb new file mode 100644 index 0000000000..4edfb36259 --- /dev/null +++ b/app/sidekiq/check_unit_similarity_job.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# On-demand plagiarism rescan for a single unit. Wraps Unit#check_jplag_similarity +# so a convenor can trigger a scan from the product instead of waiting for the +# nightly cron, which is the only thing that ran it before. +# +# Locked until_executed and rejecting on conflict, keyed on the unit id, so two +# convenors pressing the button cannot queue duplicate scans for the same unit. +class CheckUnitSimilarityJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + # Two entry points, both keyed on the unit id so a nightly scan and an on-demand + # scan of the same unit reject each other rather than racing on the shared + # tmp/jplag working directory. + # + # - No unit id: the config/schedule.yml cron enqueues this way. It fans out one + # child job per active unit with force off, so only units whose files or task + # definitions changed are rescanned, and one unit's failure does not abort the + # rest. Each child locks on its own unit id. + # - A unit id: one unit is scanned. The endpoint passes force true so a threshold + # change alone is enough to rescan, which is the gap this path exists to close; + # the nightly children pass force false. + # + # task_definition_id is accepted so the queued job and its lock key are stable if + # per-definition scanning is added later. check_jplag_similarity is unit-scoped + # today, so the scan currently covers the whole unit regardless. + def perform(unit_id = nil, force = nil, task_definition_id = nil) + at(0) + total(1) + + if unit_id.present? + logger.info "Starting similarity scan for unit #{unit_id} (force=#{force})..." + if task_definition_id.present? + logger.info "Similarity scan requested for task definition #{task_definition_id}; " \ + "running a unit-wide scan because check_jplag_similarity is unit-scoped." + end + Unit.find(unit_id).check_jplag_similarity(force: force) + else + logger.info 'Fanning out nightly similarity scans for active units...' + Unit.active_units.find_each { |unit| CheckUnitSimilarityJob.perform_async(unit.id, false) } + end + + at(1) + logger.info 'Completed similarity scan dispatch!' + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb index fa279e1124..eecca0fe03 100644 --- a/app/sidekiq/execute_communication_set_job.rb +++ b/app/sidekiq/execute_communication_set_job.rb @@ -150,16 +150,38 @@ def execute_email_student_action(action, projects, unit, rule) subject = render_template(action.subject, project, unit, rule, projects.length) body = render_template(action.body, project, unit, rule, projects.length) - CommunicationsMailer.communication_email( - to: formatted_email(recipient), - from: sender, - subject: subject, - body: body, - recipient: recipient, - sender: sender_user_for(unit), - unit: unit, - rule: rule - ).deliver_now + begin + CommunicationsMailer.communication_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule + ).deliver_now + rescue StandardError => e + # One unroutable address used to take the whole run down. The job then + # retried from the top and re-mailed everybody it had already reached, + # because nothing here records who has been sent to. Record the failure + # against the one recipient and carry on. StandardError and not + # Exception, so an Interrupt or a SIGTERM still stops the job. + logger.error( + "ExecuteCommunicationSetJob delivery failed for project #{project.id} " \ + "<#{recipient.email}>: #{e.class} #{e.message}" + ) + + next { + action_id: action.id, + action_type: action.type, + status: 'failed', + project_id: project.id, + username: recipient.username, + recipient_email: recipient.email, + reason: e.message + } + end { action_id: action.id, @@ -448,6 +470,8 @@ def build_action_log_csv(rule, projects, action_results) elsif result[:status] == 'commented' task_definition = TaskDefinition.find_by(id: result[:task_definition_id]) "Added comment to #{task_definition_label(task_definition)}" + elsif result[:status] == 'failed' + "Failed to send email to #{result[:recipient_email]}: #{result[:reason]}" elsif result[:recipient_email].present? "Sent email to #{result[:recipient_email]}" else diff --git a/app/sidekiq/import_students_lti_job.rb b/app/sidekiq/import_students_lti_job.rb index 18a6d3f9f4..edb8f7e6ea 100644 --- a/app/sidekiq/import_students_lti_job.rb +++ b/app/sidekiq/import_students_lti_job.rb @@ -9,6 +9,7 @@ class ImportStudentsLtiJob include MimeCheckHelpers include CsvHelper include LtiHelper + include FederatedIdentityHelper sidekiq_options lock: :until_executed, lock_args_method: ->(args) { [args.first] }, @@ -43,9 +44,10 @@ def perform(unit_id, members) username: member["email"][/(.*)@/, 1] } - user = User.find_by(login_id: user_id_data[:login_id]) || - User.find_by(username: user_id_data[:username]) || - User.find_by(email: user_id_data[:email]) || + user = user_for_asserted_identity(login_id: user_id_data[:login_id], + email: user_id_data[:email], + derived_username: user_id_data[:username], + source: "Lti import of unit #{unit.id}") || User.create! do |new_user| # Update new user with details from the SAML response Doubtfire::Application.config.institution_settings.update_user_from_lti_response( diff --git a/app/sidekiq/new_task_available_notification_job.rb b/app/sidekiq/new_task_available_notification_job.rb new file mode 100644 index 0000000000..81a4d5c17d --- /dev/null +++ b/app/sidekiq/new_task_available_notification_job.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +class NewTaskAvailableNotificationJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'new_task_available' + TYPE = 'task' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: 3 + + def self.enqueue(task_definition_id) + perform_async(task_definition_id) + rescue StandardError => e + Rails.logger.error( + "Failed to enqueue new-task notification for TaskDefinition #{task_definition_id}: " \ + "#{e.class} - #{e.message}" + ) + nil + end + + def self.track_and_enqueue(task_definition) + task_definition.enable_new_task_notifications! + enqueue(task_definition.id) + rescue StandardError => e + Rails.logger.error( + "Failed to track new-task notification for TaskDefinition #{task_definition.id}: " \ + "#{e.class} - #{e.message}" + ) + enqueue(task_definition.id) + end + + def self.track_and_enqueue_all(task_definitions) + task_definition_ids = task_definitions.map do |task_definition| + begin + task_definition.enable_new_task_notifications! + rescue StandardError => e + Rails.logger.error( + "Failed to track new-task notification for TaskDefinition #{task_definition.id}: " \ + "#{e.class} - #{e.message}" + ) + end + + task_definition.id + end + + perform_bulk(task_definition_ids.map { |id| [id] }) unless task_definition_ids.empty? + rescue StandardError => e + Rails.logger.error( + "Failed to bulk enqueue new-task notifications: #{e.class} - #{e.message}" + ) + nil + end + + def self.deliver(project, task_definition) + notification = nil + + # Recheck mutable eligibility under a short row lock. The reservation is + # committed before channel jobs are handed off; provider network I/O occurs + # in workers and never holds the project lock. + project.with_lock do + project.reload + unit = project.unit + eligible = unit.active && project.enrolled && project.target_grade.present? && + task_definition.target_grade <= project.target_grade + + if eligible + notification = NotificationService.reserve( + user: project.student, + type: TYPE, + event: EVENT, + message: "A new task is available: #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}", + dedupe_key: "#{EVENT}:task-definition:#{task_definition.id}" + ) + end + end + + NotificationService.deliver(notification) + end + + def perform(task_definition_id) + task_definition = TaskDefinition.find_by(id: task_definition_id) + return if task_definition.nil? + + # If the workflow's best-effort marker write failed, the queued job repairs + # it before checking availability. A transient database failure raises and + # lets Sidekiq retry instead of losing a future release permanently. + task_definition.enable_new_task_notifications! if task_definition.new_task_notifications_from.nil? + + unit = task_definition.unit + return unless unit.active + + failed_project_ids = [] + + unit.projects.where(enrolled: true).includes(:user).find_in_batches(batch_size: BATCH_SIZE) do |projects| + tasks = Task.where( + project_id: projects.map(&:id), + task_definition_id: task_definition.id + ).includes({ project: :unit }, task_definition: :grade_due_dates).index_by(&:project_id) + + projects.each do |project| + notify_project(project, task_definition, tasks[project.id]) + rescue StandardError => e + failed_project_ids << project.id + + Rails.logger.error( + "Failed new-task notification for TaskDefinition #{task_definition.id}, " \ + "Project #{project.id}: #{e.class} - #{e.message}" + ) + end + end + + return if failed_project_ids.empty? + + raise "New-task notifications failed for projects: #{failed_project_ids.join(', ')}" + end + + private + + def notify_project(project, task_definition, task) + return if project.target_grade.nil? + return if task_definition.target_grade > project.target_grade + + # A newly created task is only available when the student's effective + # start date has arrived. Webcal applies flexible, grade-specific and + # student-specific dates without creating a Task row just to notify. + available_on = Webcal.start_date_for_task_definition( + task_definition, + task, + project + ) + return if available_on.to_date > Time.zone.today + + self.class.deliver(project, task_definition) + end +end diff --git a/app/sidekiq/notification_email_job.rb b/app/sidekiq/notification_email_job.rb new file mode 100644 index 0000000000..965136b9a0 --- /dev/null +++ b/app/sidekiq/notification_email_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class NotificationEmailJob + include Sidekiq::Job + + # The queue carries only the stable Notification id. Message content, + # recipient details and other student data remain in the database and are + # loaded by the worker. + # + # Student facing email runs on its own queue so it does not wait behind a + # multi minute PDF build or CSV export on the default queue. A worker has to + # be listening on `mailers` for any of this to be picked up. + sidekiq_options queue: :mailers, retry: 3 + + def perform(notification_id) + # A producer may enqueue from inside a wider database transaction. Raising + # on a not-yet-visible row makes Sidekiq retry after that transaction commits + # instead of acknowledging and permanently dropping the delivery. + notification = Notification.find(notification_id) + + # The category preference was checked when the notification was raised, but + # a retried job can run hours later. Ask again so a preference the user has + # switched off in the meantime stays off. + return unless NotificationService.deliver_to?(notification.user, notification.notification_type) + + NotificationsMailer.single_notification(notification).deliver_now + end +end diff --git a/app/sidekiq/push_notification_delivery_job.rb b/app/sidekiq/push_notification_delivery_job.rb new file mode 100644 index 0000000000..a93da07d9e --- /dev/null +++ b/app/sidekiq/push_notification_delivery_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PushNotificationDeliveryJob + include Sidekiq::Job + + # Keep provider network I/O off `default`. The development stack cannot + # safely consume that queue because it also contains submission/PDF jobs + # whose supporting services are not present there. + sidekiq_options queue: :notifications, retry: 3 + + # Redis carries only the stable database id. The worker reloads the current + # notification and subscription state immediately before delivery. + def perform(notification_id) + # A producer may enqueue from inside a wider database transaction. Raising + # on a not-yet-visible row makes Sidekiq retry after that transaction commits + # instead of acknowledging and permanently dropping the delivery. + notification = Notification.find(notification_id) + PushNotificationService.deliver(notification) + end +end diff --git a/app/sidekiq/send_due_soon_reminders_job.rb b/app/sidekiq/send_due_soon_reminders_job.rb new file mode 100644 index 0000000000..c72048c9d7 --- /dev/null +++ b/app/sidekiq/send_due_soon_reminders_job.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# Remind students about work that is nearly due. +# +# Every other notification in this feature hangs off something a person did: a +# comment was posted, a due date was edited, a status changed. A deadline +# approaching is nobody doing anything, so there is no model to hook and this +# has to be swept for on a schedule. config/schedule.yml runs it. +# +# Recipients come from projects and not from Task rows. OnTrack creates a Task +# row the first time anyone touches the task, so the students who have not +# started have no row, and they are exactly the ones a reminder is for. +# +# Nothing here may call Project#task_for_task_definition, which creates the row +# it cannot find, and nothing may call Project#task_definitions_and_status +# either, because that calls it. This reads project.tasks once per project and +# looks the row up in a hash instead. +class SendDueSoonRemindersJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'task_due_soon' + TYPE = 'task' + + # How far ahead counts as soon, in days. + # + # Three, which is long enough to still do something about it over a weekend + # and short enough that the reminder is about this task rather than about the + # rest of the trimester. Project#top_tasks uses seven for the same idea, and + # seven days of warning on a weekly task is most of the tasks a student has, + # which is a list rather than a reminder. + WINDOW_DAYS = 3 + + # The statuses that mean the student still owes work. + # + # :discuss and :demonstrate are deliberately out. Both mean the student has + # submitted and is waiting on a tutor, so telling them their task is due soon + # is both wrong and the kind of wrong that makes people stop reading + # notifications. Everything past those, complete and fail and the rest, is + # finished with as far as a deadline is concerned. + OUTSTANDING_STATUSES = %i[ + not_started + working_on_it + need_help + fix_and_resubmit + redo + ].freeze + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['send-due-soon-reminders'] }, + on_conflict: :reject, + retry: 1 + + def perform + today = Time.zone.today + horizon = today + WINDOW_DAYS.days + failed_project_ids = [] + + # Units first and then their projects, the same shape as + # NewTaskAvailableNotificationJob, so the unit and its task definitions are + # loaded once per cohort rather than once per student. + Unit.where(active: true).find_each(batch_size: BATCH_SIZE) do |unit| + remind_unit(unit, today, horizon, failed_project_ids) + end + + return if failed_project_ids.empty? + + # Collected and re-raised at the end rather than swallowed, which is what + # NewTaskAvailableNotificationJob does and for the same reason. Logging and + # carrying on would leave perform successful, Sidekiq would schedule no + # retry, and a student whose task is due today is filtered out as overdue + # tomorrow, so that reminder is gone for good. Re-running the whole sweep is + # safe because of the duplicate guard in notify. + raise "Due-soon reminders failed for projects: #{failed_project_ids.join(', ')}" + end + + private + + def remind_unit(unit, today, horizon, failed_project_ids) + # Read once for the whole cohort. Asking per project is where the query + # count runs away: five hundred students against twenty task definitions is + # five hundred of the same query. + task_definitions = unit.task_definitions.to_a + return if task_definitions.empty? + + unit.active_projects + .where.not(target_grade: nil) + .includes(:user) + .find_each(batch_size: BATCH_SIZE) do |project| + remind_project(project, unit, task_definitions, today, horizon) + rescue StandardError => e + failed_project_ids << project.id + + Rails.logger.error( + "Failed due-soon reminders for Project #{project.id}: #{e.class} - #{e.message}" + ) + end + end + + def remind_project(project, unit, task_definitions, today, horizon) + # One query for this student's rows, then look each one up. The row is only + # read, never created. + tasks = project.tasks.includes(:task_status, :task_definition).index_by(&:task_definition_id) + + task_definitions.each do |task_definition| + next if task_definition.target_grade > project.target_grade + + task = tasks[task_definition.id] + next unless outstanding?(task) + + due = due_date_for(task_definition, task, project) + next if due.nil? + + due = due.to_date + next if due < today || due > horizon + + notify(project, unit, task_definition) + end + end + + # Whether this student still owes work on this task. + # + # No row means nobody has touched it, which is not_started by any other name + # and is the state a reminder is most for. + def outstanding?(task) + return true if task.nil? + + OUTSTANDING_STATUSES.include?(task.status) + end + + # When this task is due for this student. + # + # Webcal already answers exactly this question, for exactly this pair of + # cases, and it is what the calendar feed shows the student. Writing it again + # here would mean two answers to "when is this due" that could disagree. + # + # With a Task row it is Task#local_due_date, which knows about extensions and + # about a unit's flexible dates. Without one it is still not simply the task + # definition's target date: on a unit with flexible dates the grade level + # override applies before any row exists, so a grade 2 student can be due days + # away from the unit's own date without ever having opened the task. + def due_date_for(task_definition, task, project) + Webcal.end_date_for_task_definition(task_definition, task, project) + end + + def notify(project, unit, task_definition) + student = project.student + link = "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + + # One reminder per student per task, ever. + # + # This job runs again tomorrow and the task is still due soon tomorrow, so + # without this the same student is reminded every morning until the deadline + # passes. The index on (user_id, event) is what makes asking cheap enough to + # do once per candidate task. + return if Notification.exists?( + user_id: student.id, + notification_type: TYPE, + event: EVENT, + link: link + ) + + NotificationService.notify( + user: student, + type: TYPE, + event: EVENT, + message: "#{task_definition.abbreviation} in #{unit.code} is due soon.", + link: link + ) + end +end diff --git a/app/sidekiq/send_new_task_available_notifications_job.rb b/app/sidekiq/send_new_task_available_notifications_job.rb new file mode 100644 index 0000000000..5d9969c291 --- /dev/null +++ b/app/sidekiq/send_new_task_available_notifications_job.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +# Notify students when a future-dated task becomes available. +class SendNewTaskAvailableNotificationsJob + include Sidekiq::Job + + BATCH_SIZE = 100 + CATCH_UP_DAYS = 7 + SETTLE_TIME = 1.hour + ROLLING_WRITER_TOLERANCE = 1.minute + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['send-new-task-available-notifications'] }, + on_conflict: :reject, + retry: 3 + + def perform + today = Time.zone.today + failed_project_ids = [] + + Unit.where(active: true).find_each(batch_size: BATCH_SIZE) do |unit| + notify_unit(unit, today, failed_project_ids) + end + + return if failed_project_ids.empty? + + raise "New-task availability notifications failed for projects: #{failed_project_ids.join(', ')}" + end + + private + + def notify_unit(unit, today, failed_project_ids) + task_definitions = candidate_task_definitions(unit, today) + return if task_definitions.empty? + + unit.active_projects + .where.not(target_grade: nil) + .includes(:user) + .find_in_batches(batch_size: BATCH_SIZE) do |projects| + tasks_by_project = Task + .where( + project_id: projects.map(&:id), + task_definition_id: task_definitions.map(&:id) + ) + .includes({ project: :unit }, task_definition: :grade_due_dates) + .group_by(&:project_id) + .transform_values { |tasks| tasks.index_by(&:task_definition_id) } + + projects.each do |project| + notify_project( + project, + task_definitions, + tasks_by_project.fetch(project.id, {}), + today + ) + rescue StandardError => e + failed_project_ids << project.id + Rails.logger.error( + "Failed new-task availability notifications for Project #{project.id}: " \ + "#{e.class} - #{e.message}" + ) + end + end + end + + def candidate_task_definitions(unit, today) + # Definitions written by an older process during a rolling deployment get + # the database-default marker. Give multi-step imports/copies time to finish + # before the sweep can observe them; current workflows enqueue explicitly. + tracked = unit.task_definitions + .where.not(new_task_notifications_from: nil) + .where('created_at <= ?', Time.current - SETTLE_TIME) + window = (today - CATCH_UP_DAYS.days).beginning_of_day..today.end_of_day + + ids = tracked.where(created_at: window).ids + ids.concat(tracked.where(start_date: window).ids) + ids.concat( + TaskDefinitionGradeDueDate.where( + task_definition_id: tracked.select(:id), + start_date: window + ).distinct.pluck(:task_definition_id) + ) + ids.concat( + Task.where( + task_definition_id: tracked.select(:id), + target_start_date: window + ).distinct.pluck(:task_definition_id) + ) + ids.concat( + Task.where(task_definition_id: tracked.select(:id)) + .where('extensions < 0') + .distinct + .pluck(:task_definition_id) + ) + + tracked.where(id: ids.uniq).includes(:grade_due_dates).to_a + end + + def notify_project(project, task_definitions, tasks, today) + task_definitions.each do |task_definition| + next if task_definition.target_grade > project.target_grade + + available_on = Webcal.start_date_for_task_definition( + task_definition, + tasks[task_definition.id], + project + ).to_date + + tracking_from = task_definition.new_task_notifications_from.to_date + recently_created = task_definition.created_at >= + task_definition.new_task_notifications_from - ROLLING_WRITER_TOLERANCE && + task_definition.created_at.to_date >= today - CATCH_UP_DAYS.days + release_in_window = available_on.between?( + [tracking_from, today - CATCH_UP_DAYS.days].max, + today + ) + next unless recently_created || release_in_window + next if available_on > today + + NewTaskAvailableNotificationJob.deliver(project, task_definition) + end + end +end diff --git a/app/sidekiq/task_due_date_changed_notification_job.rb b/app/sidekiq/task_due_date_changed_notification_job.rb new file mode 100644 index 0000000000..63a80fa7b2 --- /dev/null +++ b/app/sidekiq/task_due_date_changed_notification_job.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class TaskDueDateChangedNotificationJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'task_due_date_changed' + TYPE = 'task' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args.first(3) }, + on_conflict: :reject, + retry: false + + def perform(task_definition_id, _previous_due_date, new_due_date) + task_definition = TaskDefinition.find_by(id: task_definition_id) + return if task_definition.nil? + return unless task_definition.unit.active + return unless current_due_date(task_definition) == new_due_date + + eligible_projects(task_definition).find_each(batch_size: BATCH_SIZE) do |project| + notify_project(project, task_definition) + end + end + + private + + def eligible_projects(task_definition) + task_definition.unit + .active_projects + .where( + 'projects.target_grade >= ?', + task_definition.target_grade + ) + .includes(:user) + end + + def current_due_date(task_definition) + task_definition[:due_date]&.to_date&.iso8601 + end + + def notify_project(project, task_definition) + NotificationService.notify( + user: project.student, + type: TYPE, + event: EVENT, + message: "The due date for #{task_definition.abbreviation} " \ + "in #{task_definition.unit.code} has changed.", + link: "/projects/#{project.id}/dashboard/" \ + "#{task_definition.abbreviation}" + ) + rescue StandardError => e + Rails.logger.error( + "Failed due-date notification for TaskDefinition " \ + "#{task_definition.id}, Project #{project.id}: " \ + "#{e.class} - #{e.message}" + ) + end +end diff --git a/app/views/communications_mailer/communication_email.text.erb b/app/views/communications_mailer/communication_email.text.erb index 5ad89c15fb..0b035e6c74 100644 --- a/app/views/communications_mailer/communication_email.text.erb +++ b/app/views/communications_mailer/communication_email.text.erb @@ -1,11 +1,11 @@ -<%# Hi <%= @recipient.nickname.presence || @recipient.first_name %> %> +Hi <%= @recipient&.nickname.presence || @recipient&.first_name %>, <% @body_paragraphs.each do |paragraph| %> <%= paragraph %> <% end %> -<%# Cheers, -The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> %> +Cheers, +The <%= @doubtfire_product_name %> Team on behalf of <%= @sender&.name %> --- diff --git a/app/views/notifications_mailer/discussion_request_created.html.erb b/app/views/notifications_mailer/discussion_request_created.html.erb new file mode 100644 index 0000000000..4887fd9866 --- /dev/null +++ b/app/views/notifications_mailer/discussion_request_created.html.erb @@ -0,0 +1,24 @@ +

Hi <%= @user.name %>,

+ +

+ A discussion prompt is ready for you in <%= @doubtfire_product_name %>. +

+ +

+ The prompt is not included in this email. Open the task to listen and + respond. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your feedback notifications are turned on. + You can change that at + your profile. +

diff --git a/app/views/notifications_mailer/discussion_request_created.text.erb b/app/views/notifications_mailer/discussion_request_created.text.erb new file mode 100644 index 0000000000..7ac7375526 --- /dev/null +++ b/app/views/notifications_mailer/discussion_request_created.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +A discussion prompt is ready for you in <%= @doubtfire_product_name %>. + +The prompt is not included in this email. Open the task to listen and respond. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You are receiving this because your feedback notifications are turned on. +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/extension_assessed.html.erb b/app/views/notifications_mailer/extension_assessed.html.erb new file mode 100644 index 0000000000..2fc33dabfb --- /dev/null +++ b/app/views/notifications_mailer/extension_assessed.html.erb @@ -0,0 +1,20 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ Your extension request has been assessed. + Open the task in <%= @doubtfire_product_name %> to review the current task details. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ This notification is sent when an extension request is assessed. +

\ No newline at end of file diff --git a/app/views/notifications_mailer/extension_assessed.text.erb b/app/views/notifications_mailer/extension_assessed.text.erb new file mode 100644 index 0000000000..a5042f2587 --- /dev/null +++ b/app/views/notifications_mailer/extension_assessed.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +Your extension request has been assessed. +Open the task in <%= @doubtfire_product_name %> to review the current task details. + +<% if @notification.link.present? -%> +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +This notification is sent when an extension request is assessed. \ No newline at end of file diff --git a/app/views/notifications_mailer/group_membership_changed.html.erb b/app/views/notifications_mailer/group_membership_changed.html.erb new file mode 100644 index 0000000000..cadc8b7c4d --- /dev/null +++ b/app/views/notifications_mailer/group_membership_changed.html.erb @@ -0,0 +1,20 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ This notification was sent because your group membership changed in + <%= @doubtfire_product_name %>. +

+ +

+ You can view your current group information in <%= @doubtfire_product_name %>. +

+ +<% if @notification.link.present? %> +

+ + Open your group in <%= @doubtfire_product_name %> + +

+<% end %> diff --git a/app/views/notifications_mailer/group_membership_changed.text.erb b/app/views/notifications_mailer/group_membership_changed.text.erb new file mode 100644 index 0000000000..cec5fa9de5 --- /dev/null +++ b/app/views/notifications_mailer/group_membership_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +This notification was sent because your group membership changed in <%= @doubtfire_product_name %>. + +You can view your current group information in <%= @doubtfire_product_name %>. +<% if @notification.link.present? -%> + +Open your group in <%= @doubtfire_product_name %>: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> diff --git a/app/views/notifications_mailer/new_task_available.html.erb b/app/views/notifications_mailer/new_task_available.html.erb new file mode 100644 index 0000000000..fcd4223fd9 --- /dev/null +++ b/app/views/notifications_mailer/new_task_available.html.erb @@ -0,0 +1,22 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ A new task is now available in <%= @doubtfire_product_name %>. + Open the task to review its details. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at + your profile. +

\ No newline at end of file diff --git a/app/views/notifications_mailer/new_task_available.text.erb b/app/views/notifications_mailer/new_task_available.text.erb new file mode 100644 index 0000000000..a53d604fc8 --- /dev/null +++ b/app/views/notifications_mailer/new_task_available.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +A new task is now available in <%= @doubtfire_product_name %>. +Open the task to review its details. + +<% if @notification.link.present? -%> +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. \ No newline at end of file diff --git a/app/views/notifications_mailer/portfolio_received.html.erb b/app/views/notifications_mailer/portfolio_received.html.erb new file mode 100644 index 0000000000..18510bd165 --- /dev/null +++ b/app/views/notifications_mailer/portfolio_received.html.erb @@ -0,0 +1,21 @@ +

Hi <%= @user.first_name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

+ + View the current status in <%= @doubtfire_product_name %> + +

+<% end %> + +

+ This receipt confirms when the submission was received. It does not confirm + an assessment outcome. +

+ +

+ You can manage your notification preferences in + your profile. +

diff --git a/app/views/notifications_mailer/portfolio_received.text.erb b/app/views/notifications_mailer/portfolio_received.text.erb new file mode 100644 index 0000000000..666718aedd --- /dev/null +++ b/app/views/notifications_mailer/portfolio_received.text.erb @@ -0,0 +1,14 @@ +Hi <%= @user.first_name %>, + +<%= @notification.message %> + +Open <%= @doubtfire_product_name %> to view its current status. +<% if @notification.link.present? -%> + +View the current status: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +This receipt confirms when the submission was received. It does not confirm an assessment outcome. + +You can manage your notification preferences here: +<%= @unsubscribe_url %> diff --git a/app/views/notifications_mailer/single_notification.html.erb b/app/views/notifications_mailer/single_notification.html.erb new file mode 100644 index 0000000000..0538d63457 --- /dev/null +++ b/app/views/notifications_mailer/single_notification.html.erb @@ -0,0 +1,12 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

View in <%= @doubtfire_product_name %>

+<% end %> + +

+ You are receiving this because your notification preferences allow it. + You can update them at your profile. +

diff --git a/app/views/notifications_mailer/single_notification.text.erb b/app/views/notifications_mailer/single_notification.text.erb new file mode 100644 index 0000000000..af0c03b196 --- /dev/null +++ b/app/views/notifications_mailer/single_notification.text.erb @@ -0,0 +1,9 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> +<% if @notification.link.present? -%> + +View it here: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can update your notification preferences at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_comment_created.html.erb b/app/views/notifications_mailer/task_comment_created.html.erb new file mode 100644 index 0000000000..3ddbeb7a26 --- /dev/null +++ b/app/views/notifications_mailer/task_comment_created.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The comment is not included in this email. Open the task in + <%= @doubtfire_product_name %> to read it and reply. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your feedback notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_comment_created.text.erb b/app/views/notifications_mailer/task_comment_created.text.erb new file mode 100644 index 0000000000..375494cc38 --- /dev/null +++ b/app/views/notifications_mailer/task_comment_created.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The comment is not included in this email. Open the task in <%= @doubtfire_product_name %> to read it and reply. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_due_date_changed.html.erb b/app/views/notifications_mailer/task_due_date_changed.html.erb new file mode 100644 index 0000000000..8e8c044244 --- /dev/null +++ b/app/views/notifications_mailer/task_due_date_changed.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The new due date is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see it. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_due_date_changed.text.erb b/app/views/notifications_mailer/task_due_date_changed.text.erb new file mode 100644 index 0000000000..a9c61af991 --- /dev/null +++ b/app/views/notifications_mailer/task_due_date_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The new due date is not included in this email. Open the task in <%= @doubtfire_product_name %> to see it. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_due_soon.html.erb b/app/views/notifications_mailer/task_due_soon.html.erb new file mode 100644 index 0000000000..345e15906e --- /dev/null +++ b/app/views/notifications_mailer/task_due_soon.html.erb @@ -0,0 +1,22 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The deadline is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see when it is due and what is left to do. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at + your profile. +

diff --git a/app/views/notifications_mailer/task_due_soon.text.erb b/app/views/notifications_mailer/task_due_soon.text.erb new file mode 100644 index 0000000000..76c532794d --- /dev/null +++ b/app/views/notifications_mailer/task_due_soon.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The deadline is not included in this email. Open the task in <%= @doubtfire_product_name %> to see when it is due and what is left to do. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You are receiving this because your task notifications are turned on. +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_status_changed.html.erb b/app/views/notifications_mailer/task_status_changed.html.erb new file mode 100644 index 0000000000..4b19235318 --- /dev/null +++ b/app/views/notifications_mailer/task_status_changed.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The new status is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see it. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_status_changed.text.erb b/app/views/notifications_mailer/task_status_changed.text.erb new file mode 100644 index 0000000000..53f7f7f256 --- /dev/null +++ b/app/views/notifications_mailer/task_status_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The new status is not included in this email. Open the task in <%= @doubtfire_product_name %> to see it. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_submitted.html.erb b/app/views/notifications_mailer/task_submitted.html.erb new file mode 100644 index 0000000000..55475178ce --- /dev/null +++ b/app/views/notifications_mailer/task_submitted.html.erb @@ -0,0 +1,18 @@ +

Hi <%= @user.first_name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

+ + Open the task in <%= @doubtfire_product_name %> + +

+<% end %> + +

The submission and any assessment content are not included in this email.

+ +

+ You can manage your notification preferences in + your profile. +

diff --git a/app/views/notifications_mailer/task_submitted.text.erb b/app/views/notifications_mailer/task_submitted.text.erb new file mode 100644 index 0000000000..d08ccfa52e --- /dev/null +++ b/app/views/notifications_mailer/task_submitted.text.erb @@ -0,0 +1,13 @@ +Hi <%= @user.first_name %>, + +<%= @notification.message %> + +Open the task to review the submission: +<% if @notification.link.present? -%> +<%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +The submission and any assessment content are not included in this email. + +You can manage your notification preferences here: +<%= @unsubscribe_url %> diff --git a/app/views/notifications_mailer/tutorial_changed.html.erb b/app/views/notifications_mailer/tutorial_changed.html.erb new file mode 100644 index 0000000000..ee4790c6c1 --- /dev/null +++ b/app/views/notifications_mailer/tutorial_changed.html.erb @@ -0,0 +1,15 @@ +

Hi <%= @user.name %>,

+ +

Your tutorial has changed.

+ +

<%= @notification.message %>

+ +

Please use the new tutorial day and time for your next class.

+ +<% if @notification.link.present? %> +

+ + Open your unit in <%= @doubtfire_product_name %> + +

+<% end %> diff --git a/app/views/notifications_mailer/tutorial_changed.text.erb b/app/views/notifications_mailer/tutorial_changed.text.erb new file mode 100644 index 0000000000..551cfe6ee2 --- /dev/null +++ b/app/views/notifications_mailer/tutorial_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +Your tutorial has changed. + +<%= @notification.message %> + +Please use the new tutorial day and time for your next class. +<% if @notification.link.present? -%> + +Open your unit in <%= @doubtfire_product_name %>: <%= @doubtfire_host %><%= @notification.link %> +<% 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..221e96335c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -66,7 +66,7 @@ class Application < Rails::Application # 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 +140,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'] @@ -255,9 +256,15 @@ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) 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 ) # Grape Serialization diff --git a/config/deakin.rb b/config/deakin.rb index c62ef0a292..e48cb92f0e 100644 --- a/config/deakin.rb +++ b/config/deakin.rb @@ -154,8 +154,7 @@ def sync_streams_from_star(unit) activityData.each do |activity| # Make sure units match - subject_match = /.*?(?=_)/.match(activity["subject_code"]) - unit_code = subject_match.nil? ? nil : subject_match[0] + unit_code = value_before_delimiter(activity['subject_code'], '_') unless unit_code == unit.code logger.error "Failed to sync #{unit.code} - response had unit code #{enrolmentData['unitCode']}" return @@ -185,11 +184,15 @@ def sync_streams_from_star(unit) end end + def value_before_delimiter(value, delimiter) + string = value.to_s + delimiter_index = string.index(delimiter) + string[0...delimiter_index] unless delimiter_index.nil? + end + def fetch_star_row(row, unit) - email_match = /(.*)(?=@)/.match(row["email_address"]) - subject_match = /.*?(?=_)/.match(row["subject_code"]) - username = email_match.nil? ? nil : email_match[0] - unit_code = subject_match.nil? ? nil : subject_match[0] + username = value_before_delimiter(row['email_address'], '@') + unit_code = value_before_delimiter(row['subject_code'], '_') tutorial_code = fetch_tutorial unit, row diff --git a/config/environments/development.rb b/config/environments/development.rb index 05d01df74c..f87cbef55d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -60,9 +60,28 @@ config.action_mailer.perform_caching = false - # Tell Action Mailer not to deliver emails to the real world. - # Write them to file instead (under doubtfire-api/tmp/mails) - config.action_mailer.delivery_method = :file + # Never deliver email to the real world in development. + # + # With docker (the normal case), DF_SMTP_ADDRESS points at the mailpit + # container and every email shows up in a web inbox at http://localhost:8025. + # Mailpit accepts everything and forwards nothing. + # + # Without it, mail is written to a file instead. Under docker that file lands + # on the host at doubtfire-deploy/data/tmp/mails/, NOT in this repository, + # because development/docker-compose.yml mounts ../data/tmp over /doubtfire/tmp + # and Rails.root in the container is /doubtfire. Looking for it under + # doubtfire-api/tmp/mails shows an empty folder and makes email look broken. + # + # See doubtfire-deploy/RUNNING-LOCALLY.md. + if ENV['DF_SMTP_ADDRESS'].present? + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV['DF_SMTP_ADDRESS'], + port: ENV.fetch('DF_SMTP_PORT', 1025).to_i + } + else + config.action_mailer.delivery_method = :file + end # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 0000000000..d483f94916 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,76 @@ +require 'digest' +require 'json' + +# Rack::Attack 6.8 uses Rack::Request, whose params do not parse JSON request +# bodies. Use Rails' parser, which rewinds and caches rack.input for the app. +module Rack + class Attack + class Request + AUTH_PATH = %r{\A/api/auth(?:\.json)?\z} + + def password_authentication_request? + post? && path.match?(AUTH_PATH) + end + + def authentication_username_digest + username = + if media_type == 'application/json' + ActionDispatch::Request.new(env).request_parameters['username'] + else + params['username'] + end + return unless username.is_a?(String) + + normalized_username = username.downcase.strip + Digest::SHA256.hexdigest(normalized_username) if normalized_username.present? + rescue ActionDispatch::Http::Parameters::ParseError + nil + end + end + end +end + +# Prefer the application's dedicated Redis cache and fall back to the mandatory +# Sidekiq Redis service. Process-local stores do not enforce a deployment-wide +# throttle when the API is replicated. +shared_redis_url = + ENV.fetch('DF_REDIS_CACHE_URL', nil).presence || + ENV.fetch('DF_REDIS_SIDEKIQ_URL', nil).presence + +Rack::Attack.cache.store = + if shared_redis_url.present? && !Rails.env.test? + ActiveSupport::Cache::RedisCacheStore.new( + url: shared_redis_url, + namespace: 'doubtfire:rack-attack' + ) + elsif Rails.env.local? + Rails.cache + else + raise 'Set DF_REDIS_CACHE_URL or DF_REDIS_SIDEKIQ_URL to enable authentication rate limiting' + end + +Rack::Attack.throttled_response_retry_after_header = true +Rack::Attack.throttled_responder = lambda do |request| + match_data = request.env.fetch('rack.attack.match_data') + retry_after = match_data[:period] - (match_data[:epoch_time] % match_data[:period]) + + [ + 429, + { + 'content-type' => 'application/json', + 'retry-after' => retry_after.to_s + }, + [JSON.generate(error: 'Too many authentication attempts. Please try again later.')] + ] +end + +# Limit authentication attempts from a single IP. +Rack::Attack.throttle('auth/ip', limit: 5, period: 1.minute) do |req| + req.ip if req.password_authentication_request? +end + +# Limit attempts against a single username without storing that username in the +# rate-limit cache key. +Rack::Attack.throttle('auth/username', limit: 5, period: 1.minute) do |req| + req.authentication_username_digest if req.password_authentication_request? +end diff --git a/config/institution.yml b/config/institution.yml index 74d7d7c8b0..c8324c9892 100644 --- a/config/institution.yml +++ b/config/institution.yml @@ -1,5 +1,6 @@ name: Doubtfire University email_domain: doubtfire.com +email_sender: noreply@doubtfire.local host: localhost:3000 cookie_domain: localhost product_name: Doubtfire 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.yml b/config/schedule.yml index 62fd893daf..b2ba5ebbc4 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -16,14 +16,56 @@ refresh_moderation_feedback_timestamps: cron: "every 60 minutes" class: "RefreshModerationFeedbackTimestampsJob" +aggregate_peer_progress: + cron: "every day at 11:45pm" + class: "AggregatePeerProgressJob" + aggregate_task_completion_stats: cron: "every day at 11:55pm" class: "AggregateTaskCompletionStatsJob" +# Nightly plagiarism scan. This is the run that used to live only in the +# container crontab, moved here so it is visible next to the other recurring +# jobs. The same scan can now be triggered on demand from the unit, which is +# why it belongs in Sidekiq rather than cron. +check_unit_similarity: + cron: "every day at 5" + class: "CheckUnitSimilarityJob" + poll_communication_set_schedules: cron: "every 5 minutes" class: "PollCommunicationSetSchedulesJob" +# Recheck effective task start dates once a day. Directly created, copied and +# imported tasks are queued immediately; this sweep handles students whose +# flexible or future-dated start arrives later, with a bounded catch-up window +# for short worker outages and late enrolment. +send_new_task_available_notifications: + cron: "every day at 8:10am" + class: "SendNewTaskAvailableNotificationsJob" + +# Once a day, in the morning. +# +# A deadline moves once a day, so nothing is gained by sweeping for one every +# half hour, and every notification this job raises sends an email. Pinning it +# to a time also pins when the emails arrive: on a shorter interval a student +# gets theirs at whatever hour the task happened to cross into the window, +# which is 3am about as often as any other hour. +# +# Missing a run costs nothing. The job only asks whether a task is due within +# the next three days, so the following morning still catches everything the +# skipped run would have. +# +# 8am is 8am wherever the Sidekiq process thinks it is. There is no timezone in +# this file and no config.time_zone in the app, so the hour comes from TZ in the +# process environment. development/api.env sets TZ=Australia/Melbourne, which is +# what makes this a morning; a deployment that leaves TZ unset gets the image +# default of UTC and sends these in the evening. The entry above it, at 11:55pm, +# depends on the same thing. +send_due_soon_reminders: + cron: "every day at 8am" + class: "SendDueSoonRemindersJob" + # archive_old_units: # cron: "every 6 months" # class: "ArchiveOldUnitsJob" diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 0515ae2186..0d21bf2de9 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1 +1,26 @@ -:concurrency: 1 +# Keep the historical single-worker default for small installations. Production +# deployments can raise this after sizing the database pool and worker memory. +<% sidekiq_concurrency = Integer(ENV.fetch('DF_SIDEKIQ_CONCURRENCY', '1'), 10) %> +<% raise ArgumentError, 'DF_SIDEKIQ_CONCURRENCY must be positive' unless sidekiq_concurrency.positive? %> +:concurrency: <%= sidekiq_concurrency %> + +# Strict priority, highest first. User-facing notification email and Web Push +# use `mailers` and `notifications` respectively. Each job performs one channel +# delivery, so neither must wait behind AcceptSubmissionJob or a CSV export on +# `default` at concurrency 1. +# +# This list is what makes both notification channel jobs run in production. +# That worker is started by lib/shell/sidekiq_entry_point.sh, a bare +# `bundle exec sidekiq` with no -q, so with no :queues: here Sidekiq would listen +# on `default` alone and notification channel jobs would sit in Redis unread. +# perform_async succeeds, nothing raises and nothing logs, so the failure is +# silent. +# +# The development worker passes `-q mailers -q notifications` on the command +# line, which replaces this list, so it stays narrow and keeps ignoring +# `default`. See the comment on the doubtfire-sidekiq service in doubtfire-deploy +# development/docker-compose.yml. +:queues: + - mailers + - notifications + - default diff --git a/db/migrate/20260722000001_create_notifications.rb b/db/migrate/20260722000001_create_notifications.rb new file mode 100644 index 0000000000..f935c74ed8 --- /dev/null +++ b/db/migrate/20260722000001_create_notifications.rb @@ -0,0 +1,15 @@ +class CreateNotifications < ActiveRecord::Migration[8.0] + def change + create_table :notifications do |t| + t.references :user, foreign_key: true, null: false + t.string :notification_type, null: false + t.string :message, null: false + t.string :link + t.datetime :read_at + + t.timestamps + end + + add_index :notifications, [:user_id, :read_at] + end +end diff --git a/db/migrate/20260802000001_add_event_to_notifications.rb b/db/migrate/20260802000001_add_event_to_notifications.rb new file mode 100644 index 0000000000..1209aed464 --- /dev/null +++ b/db/migrate/20260802000001_add_event_to_notifications.rb @@ -0,0 +1,21 @@ +class AddEventToNotifications < ActiveRecord::Migration[8.0] + # `notification_type` is the broad category the user's preferences switch on + # (task, feedback, portfolio, extension, general). `event` records which + # specific thing happened, so a notification can be traced back to the code + # that raised it and so we can later suppress or batch a single event without + # turning off the whole category. + def up + # Added with a placeholder default first, so the ALTER succeeds on a + # database that already has notification rows, then the default is dropped + # so new records must supply a real event. + add_column :notifications, :event, :string, null: false, default: 'legacy' + change_column_default :notifications, :event, nil + + add_index :notifications, [:user_id, :event] + end + + def down + remove_index :notifications, column: [:user_id, :event] + remove_column :notifications, :event + end +end diff --git a/db/migrate/20260802000002_change_notification_message_to_text.rb b/db/migrate/20260802000002_change_notification_message_to_text.rb new file mode 100644 index 0000000000..2a5bf739d1 --- /dev/null +++ b/db/migrate/20260802000002_change_notification_message_to_text.rb @@ -0,0 +1,17 @@ +class ChangeNotificationMessageToText < ActiveRecord::Migration[8.0] + # The model allows a 500 character message, but the column was created as a + # string, which MariaDB stores as VARCHAR(255). Anything over 255 characters + # passed validation and then failed at the database. TEXT holds the full + # validated length. + def up + change_column :notifications, :message, :text, null: false + end + + # Lossy. Narrowing back to VARCHAR(255) will reject (strict mode) or silently + # truncate (permissive mode) any message over 255 characters saved while the + # column was text. Only roll this back on a database you are willing to lose + # long messages from. + def down + change_column :notifications, :message, :string, null: false + end +end diff --git a/db/migrate/20260802000003_create_push_subscriptions.rb b/db/migrate/20260802000003_create_push_subscriptions.rb new file mode 100644 index 0000000000..8d444927ec --- /dev/null +++ b/db/migrate/20260802000003_create_push_subscriptions.rb @@ -0,0 +1,28 @@ +class CreatePushSubscriptions < ActiveRecord::Migration[8.0] + def change + create_table :push_subscriptions do |t| + t.references :user, foreign_key: true, null: false + + # The push service URL the browser hands us. We send to it, and it + # identifies the browser rather than the person, so it is unique across + # the whole table and not just per user. + # + # 500 rather than the default 255 because Firefox and Safari endpoints + # run close to 260 characters. A varchar(255) would reject them under + # strict mode and silently truncate them otherwise, and a truncated + # endpoint is a push that quietly goes nowhere. utf8mb4 makes this a + # 2000 byte index key, inside InnoDB's 3072 byte limit. + t.string :endpoint, null: false, limit: 500 + + # The browser's public key and auth secret. The payload is encrypted to + # these, so without them a push cannot be read by the browser that asked + # for it. + t.string :p256dh, null: false + t.string :auth, null: false + + t.timestamps + end + + add_index :push_subscriptions, :endpoint, unique: true + end +end diff --git a/db/migrate/20260809153000_create_peer_progress_snapshots.rb b/db/migrate/20260809153000_create_peer_progress_snapshots.rb new file mode 100644 index 0000000000..a96373436f --- /dev/null +++ b/db/migrate/20260809153000_create_peer_progress_snapshots.rb @@ -0,0 +1,32 @@ +class CreatePeerProgressSnapshots < ActiveRecord::Migration[8.0] + def change + create_table :peer_progress_snapshots, + options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ' \ + 'COLLATE=utf8mb4_general_ci' do |t| + t.references :unit, null: false + t.references :task_definition, null: false + + t.integer :target_grade, null: false + + # nil represents suppressed or unavailable data. + # A genuine zero result is stored as 0.00. + t.decimal :submitted_percentage, + precision: 5, + scale: 2 + + # Internal only. Never expose this raw value through the student API. + t.integer :cohort_size, null: false + + # The time the aggregate was calculated, rather than when this row + # happened to be inserted or updated. + t.datetime :calculated_at, null: false + + t.timestamps + end + + add_index :peer_progress_snapshots, + [:unit_id, :task_definition_id, :target_grade], + unique: true, + name: 'idx_peer_progress_unit_task_grade' + end +end diff --git a/db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb b/db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb new file mode 100644 index 0000000000..2d17007d0b --- /dev/null +++ b/db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddPeerProgressEnabledToUnits < ActiveRecord::Migration[8.0] + def change + add_column :units, + :peer_progress_enabled, + :boolean, + default: false, + null: false + end +end diff --git a/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb b/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb new file mode 100644 index 0000000000..cecc6b0620 --- /dev/null +++ b/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AddTargetGradeChangedAtToProjects < ActiveRecord::Migration[8.0] + def up + # Keep the database default after the migration. During a rolling deploy an + # older application instance does not know about this column, so its INSERT + # must still produce a valid row once the column becomes NOT NULL. + add_column :projects, + :target_grade_changed_at, + :datetime, + default: -> { 'CURRENT_TIMESTAMP(6)' } + + # Existing projects have no trustworthy record of when their current + # target grade was selected. Backfill to now so existing snapshots fail + # closed until the next successful aggregation run. + execute <<~SQL + UPDATE projects + SET target_grade_changed_at = UTC_TIMESTAMP() + WHERE target_grade_changed_at IS NULL + SQL + + change_column_null :projects, :target_grade_changed_at, false + end + + def down + remove_column :projects, :target_grade_changed_at + end +end diff --git a/db/migrate/20260824000001_track_new_task_notifications.rb b/db/migrate/20260824000001_track_new_task_notifications.rb new file mode 100644 index 0000000000..b89fcbb8ba --- /dev/null +++ b/db/migrate/20260824000001_track_new_task_notifications.rb @@ -0,0 +1,50 @@ +class TrackNewTaskNotifications < ActiveRecord::Migration[8.0] + def up + # The database default covers definitions written by an older application + # instance during a rolling deployment. Supported workflows replace it + # with their actual tracking boundary once configuration is complete. + unless column_exists?(:task_definitions, :new_task_notifications_from) + add_column( + :task_definitions, + :new_task_notifications_from, + :datetime, + default: -> { 'UTC_TIMESTAMP()' } + ) + end + + unless index_exists?(:task_definitions, :new_task_notifications_from) + add_index :task_definitions, :new_task_notifications_from + end + + unless column_exists?(:notifications, :dedupe_key) + add_column :notifications, :dedupe_key, :string, limit: 191 + end + unless column_exists?(:notifications, :delivered_at) + add_column :notifications, :delivered_at, :datetime + end + + unless index_exists?(:notifications, [:user_id, :dedupe_key], unique: true) + add_index( + :notifications, + [:user_id, :dedupe_key], + unique: true, + name: 'index_notifications_on_user_and_dedupe_key' + ) + end + end + + def down + if index_exists?(:notifications, [:user_id, :dedupe_key], name: 'index_notifications_on_user_and_dedupe_key') + remove_index :notifications, name: 'index_notifications_on_user_and_dedupe_key' + end + remove_column :notifications, :delivered_at if column_exists?(:notifications, :delivered_at) + remove_column :notifications, :dedupe_key if column_exists?(:notifications, :dedupe_key) + + if index_exists?(:task_definitions, :new_task_notifications_from) + remove_index :task_definitions, :new_task_notifications_from + end + if column_exists?(:task_definitions, :new_task_notifications_from) + remove_column :task_definitions, :new_task_notifications_from + end + end +end diff --git a/db/migrate/20260824000002_ensure_target_grade_changed_at_default.rb b/db/migrate/20260824000002_ensure_target_grade_changed_at_default.rb new file mode 100644 index 0000000000..ae825c27ad --- /dev/null +++ b/db/migrate/20260824000002_ensure_target_grade_changed_at_default.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class EnsureTargetGradeChangedAtDefault < ActiveRecord::Migration[8.0] + CURRENT_TIMESTAMP_DEFAULT = /\Acurrent_timestamp\(6\)\z/i + + def up + column = connection.columns(:projects).find do |candidate| + candidate.name == 'target_grade_changed_at' + end + raise 'projects.target_grade_changed_at must exist before its default is repaired' unless column + + return if current_timestamp_default?(column) + + change_column_default :projects, + :target_grade_changed_at, + -> { 'CURRENT_TIMESTAMP(6)' } + end + + def down + # The default is an ongoing rolling-deploy invariant, not temporary data + # needed only while this migration runs. Deliberately retain it on rollback. + end + + private + + def current_timestamp_default?(column) + value = column.default_function || column.default + value.to_s.delete(' ').match?(CURRENT_TIMESTAMP_DEFAULT) + end +end diff --git a/db/migrate/20260824000003_add_detailed_peer_progress.rb b/db/migrate/20260824000003_add_detailed_peer_progress.rb new file mode 100644 index 0000000000..1df15bbf58 --- /dev/null +++ b/db/migrate/20260824000003_add_detailed_peer_progress.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddDetailedPeerProgress < ActiveRecord::Migration[8.0] + def change + # Internal aggregate counts only. Exact upload counts are required so the + # student API can subtract the authenticated viewer before quantisation; + # reconstructing a count from the legacy rounded percentage is unsafe. + add_column :peer_progress_snapshots, :submitted_count, :integer + add_column :peer_progress_snapshots, :status_counts, :json + + # Existing and future users start opted in, while the profile endpoint can + # persist an explicit false value. + add_column :users, + :display_peer_progress, + :boolean, + default: true, + null: false + end +end diff --git a/db/migrate/20260827013000_create_consumed_lti_tokens.rb b/db/migrate/20260827013000_create_consumed_lti_tokens.rb new file mode 100644 index 0000000000..9e2eb58a3c --- /dev/null +++ b/db/migrate/20260827013000_create_consumed_lti_tokens.rb @@ -0,0 +1,14 @@ +class CreateConsumedLtiTokens < ActiveRecord::Migration[8.0] + def change + create_table :consumed_lti_tokens do |t| + t.string :jti, null: false + t.references :user, null: false, foreign_key: true + t.datetime :expires_at, null: false + + t.timestamps + end + + add_index :consumed_lti_tokens, :jti, unique: true + add_index :consumed_lti_tokens, :expires_at + end +end diff --git a/db/migrate/20260830063140_add_theme_preference_to_users.rb b/db/migrate/20260830063140_add_theme_preference_to_users.rb new file mode 100644 index 0000000000..5f37374e95 --- /dev/null +++ b/db/migrate/20260830063140_add_theme_preference_to_users.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddThemePreferenceToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :theme_preference, :string + add_column :users, :theme_preference_updated_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index b8ec5659b3..874e3e95bb 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_08_30_063140) 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 @@ -159,6 +159,17 @@ 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 "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 +351,23 @@ 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.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 +471,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 +512,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 +521,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" @@ -606,9 +663,11 @@ 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.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 ["tutorial_stream_id"], name: "index_task_definitions_on_tutorial_stream_id" t.index ["unit_id"], name: "index_task_definitions_on_unit_id" @@ -901,6 +960,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 +1016,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" @@ -987,10 +1050,13 @@ 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 "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 "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/deployApi.Dockerfile b/deployApi.Dockerfile index f5ae9529df..2e55d70046 100644 --- a/deployApi.Dockerfile +++ b/deployApi.Dockerfile @@ -1,23 +1,14 @@ -# -# deployApi.Dockerfile - the container used to host the API only -# -FROM ruby:3.4-bookworm +# Production API image. Refresh the exact base digest only through a reviewed +# dependency update and rebuild both API/app-worker images from the same commit. +FROM ruby:3.4.10-bookworm@sha256:56e0c9fdbf64d090e45072d32f0d3be7f2e392e733444f7d176a50881e6c325a -# Setup dependencies ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update \ - && apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common \ - && install -m 0755 -d /etc/apt/keyrings \ - && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ - && chmod a+r /etc/apt/keyrings/docker.asc \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list \ - && curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/redis.list - -RUN apt-get update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bc \ + ca-certificates \ + curl \ ffmpeg \ ghostscript \ imagemagick \ @@ -25,29 +16,24 @@ RUN apt-get update \ libmagickwand-dev \ libmariadb-dev \ tzdata \ - redis \ - docker-ce \ - docker-ce-cli \ - containerd.io \ - && apt-get clean + && rm -rf /var/lib/apt/lists/* -# Setup the folder where we will deploy the code WORKDIR /doubtfire -# Copy doubtfire-api source -COPY . /doubtfire/ +ENV RAILS_ENV=production \ + BUNDLE_WITHOUT=development:test:staging -# Install bundler -RUN gem install bundler -v '2.6.6' -RUN bundle config set --global without development test staging +RUN gem install bundler -v 2.6.6 --no-document -# Install the Gems -RUN bundle install +# Keep dependency installation cacheable and require the committed lockfile. +COPY Gemfile Gemfile.lock ./ +RUN bundle config set deployment true \ + && bundle install --jobs 4 --retry 3 -EXPOSE 3000 +COPY . ./ -# Set default to production -ENV RAILS_ENV production +EXPOSE 3000 -# Run migrate and server on launch -CMD bundle exec rake db:migrate && bundle exec rails s -b 0.0.0.0 +# Migrations are a separate one-shot deployment service. API startup must never +# race or silently repeat them. +CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] diff --git a/deployAppSvr.Dockerfile b/deployAppSvr.Dockerfile index 5ef8f15815..d8e402bb8d 100644 --- a/deployAppSvr.Dockerfile +++ b/deployAppSvr.Dockerfile @@ -1,56 +1,47 @@ -# -# deployAppSrc.Dockerfile - the container used for back end processing -# -FROM ruby:3.4-bookworm +# Docker CLI only: workers use a constrained remote Docker API for TexLive and +# JPlag. Never ship a daemon or containerd in this application image. +FROM docker:28.5.2-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d AS docker_cli -# Setup dependencies -ARG DEBIAN_FRONTEND=noninteractive +# Build the app-worker from the same exact Ruby base and API source as the API. +FROM ruby:3.4.10-bookworm@sha256:56e0c9fdbf64d090e45072d32f0d3be7f2e392e733444f7d176a50881e6c325a -RUN apt-get update \ - && apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common \ - && install -m 0755 -d /etc/apt/keyrings \ - && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ - && chmod a+r /etc/apt/keyrings/docker.asc \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list \ - && curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/redis.list +ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bc \ + bsd-mailx \ + ca-certificates \ + cron \ ffmpeg \ - ghostscript qpdf \ + ghostscript \ imagemagick \ libmagic-dev \ libmagickwand-dev \ libmariadb-dev \ + msmtp-mta \ python3-pygments \ + qpdf \ tzdata \ - cron \ - msmtp-mta bsd-mailx \ - redis \ - docker-ce \ - docker-ce-cli \ - containerd.io \ - && apt-get clean - -# Setup the folder where we will deploy the code + && rm -rf /var/lib/apt/lists/* + +COPY --from=docker_cli /usr/local/bin/docker /usr/local/bin/docker + WORKDIR /doubtfire -# Install bundler -RUN gem install bundler -v '2.6.6' -RUN bundle config set --global without development test staging +ENV RAILS_ENV=production \ + BUNDLE_WITHOUT=development:test:staging -# Install the Gems -COPY ./Gemfile ./Gemfile.lock /doubtfire/ -RUN bundle install +RUN gem install bundler -v 2.6.6 --no-document -# Copy doubtfire-api source -COPY . /doubtfire/ +COPY Gemfile Gemfile.lock ./ +RUN bundle config set deployment true \ + && bundle install --jobs 4 --retry 3 -# Crontab file copied to cron.d directory. -COPY ./.ci-setup/crontab /etc/cron.d/container_cronjob +COPY . ./ +COPY .ci-setup/crontab /etc/cron.d/container_cronjob -RUN touch /var/log/cron.log +RUN touch /var/log/cron.log \ + && chmod 0644 /etc/cron.d/container_cronjob -CMD /doubtfire/lib/shell/pdfgen_entry_point.sh +CMD ["/doubtfire/lib/shell/pdfgen_entry_point.sh"] 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/dashboard-feedback-state.md b/docs/dashboard-feedback-state.md new file mode 100644 index 0000000000..5160607381 --- /dev/null +++ b/docs/dashboard-feedback-state.md @@ -0,0 +1,59 @@ +# Cross-Project Dashboard Feedback State + +## Purpose + +The Cross-Project Dashboard needs to distinguish genuine staff feedback from the existing general unread comment count without exposing feedback content. + +## Response contract + +When task data is included in the authenticated student's `/api/projects` response, each task may include: + +| Field | Type | Meaning | +| --- | --- | --- | +| `has_feedback` | Boolean | Whether the task has qualifying manual staff feedback according to the existing `Task#has_manual_feedback_since_first_ready_for_feedback?` rule. | + +Example: + +```json +{ + "id": 123, + "task_definition_id": 45, + "status": "complete", + "num_new_comments": 1, + "has_feedback": true +} +``` + +## Exact meaning + +`has_feedback` is `true` when the existing task feedback rule finds at least one qualifying comment: + +- the comment type is `text`, `audio`, `image`, `pdf`, or `discussion`; +- the comment was authored by unit teaching staff; +- when the task has entered Ready for Feedback, the comment was created on or after the first Ready for Feedback event; +- the comment is not an automated message beginning with `**Automated Message:**`. + +The field reuses the existing backend feedback definition rather than introducing a dashboard-specific definition. + +## Privacy and access control + +Only the boolean feedback state is exposed. + +The dashboard response does not expose: + +- feedback text; +- marker notes; +- feedback author details; +- feedback timestamps; +- unread-feedback state; +- another student's feedback state. + +`GET /api/projects` derives projects from the authenticated `current_user`. Direct project access continues to use the existing project authorisation checks. + +## Compatibility + +Frontend consumers must treat `has_feedback` as optional. Missing feedback metadata must not prevent the Cross-Project Dashboard from loading and is treated as no available feedback state. + +## Scope + +This ticket does not add feedback text, feedback timestamps, author information, or unread-feedback tracking. Any future expansion requires a separate privacy and contract review. diff --git a/docs/email-notification-first-pass-tests.md b/docs/email-notification-first-pass-tests.md new file mode 100644 index 0000000000..92de9c4055 --- /dev/null +++ b/docs/email-notification-first-pass-tests.md @@ -0,0 +1,29 @@ +# Email Notification – First Pass Test Cases + +## Purpose + +The purpose of this document is to define an initial set of test cases for user email notification preferences and correct email delivery before implementation begins. + +This task documents expected behaviour only. No production code has been modified. + +## Test Cases + +| Test ID | Scenario | Preconditions | Test Action | Expected Result | +|---|---|---|---|---| +| EN-01 | Notifications enabled | The user has enabled email notifications and has a valid email address | Trigger a valid notification event | Exactly one email is delivered to the intended recipient | +| EN-02 | Notifications disabled | The user has disabled email notifications | Trigger the same notification event | No email is created, queued, or delivered | +| EN-03 | Wrong recipient | The event belongs to User A, while User B also exists in the system | Trigger the notification for User A | Only User A receives the email; User B receives nothing | +| EN-04 | Duplicate event | The same notification event is processed twice | Process the duplicate event | Only one email is delivered | +| EN-05 | Changed preference | The user changes the preference from enabled to disabled before the event | Trigger a notification after the preference change | The updated preference is respected and no email is delivered | + +## Key Finding + +Correct email delivery depends on validating both the user's latest notification preference and the intended recipient before sending the email. + +## Recommended Next Step + +Confirm how duplicate notification events will be identified and determine where automated tests for these scenarios should be implemented when the feature is developed. + +## Current Blocker + +The email notification feature is not yet fully implemented, so these test cases define expected behaviour only and cannot yet be executed as automated tests. \ No newline at end of file diff --git a/docs/email_notifications/safe_starter_email_templates.md b/docs/email_notifications/safe_starter_email_templates.md new file mode 100644 index 0000000000..2cad342331 --- /dev/null +++ b/docs/email_notifications/safe_starter_email_templates.md @@ -0,0 +1,173 @@ + +# Safe Starter Email Templates + +## Purpose + +These starter templates provide privacy-aware wording for OnTrack email +notifications. They avoid exposing unnecessary assessment information in email +subject lines and bodies. + +Recipients should sign in to OnTrack to view task names, unit information, +feedback, marks, comments, submissions, dates, and other assessment details. + +## Privacy Guidelines + +- Keep subject lines generic. +- Do not include student names in subject lines. +- Do not include unit names or task names in subject lines. +- Do not include marks, grades, feedback text, tutor comments, or submission details. +- Direct users to sign in to OnTrack to view protected information. +- Do not include personal information or authentication tokens in URLs. +- Respect the user's notification preferences. +- Use configured OnTrack names and URLs instead of hard-coded deployment details. + +## Suggested Placeholders + +- `{{product_name}}`: the configured system name, such as OnTrack. +- `{{sign_in_url}}`: a secure link to the OnTrack sign-in page. +- `{{notification_settings_url}}`: the user's notification settings page. + +--- + +## 1. Due Soon + +### Subject + +```text +{{product_name}} reminder: a due date is approaching +``` + +### Body + +```text +Hello, + +A task in {{product_name}} is due soon. + +Sign in to review the task and confirm the due date: +{{sign_in_url}} + +No assessment details are included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include the task name, unit name, due date, submission +status, or other assessment details. + +--- + +## 2. Feedback Available + +### Subject + +```text +{{product_name}} notification: feedback is available +``` + +### Body + +```text +Hello, + +New feedback is available in {{product_name}}. + +Sign in to view it securely: +{{sign_in_url}} + +No feedback content is included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include feedback text, task names, unit names, staff +comments, marks, or assessment results. + +--- + +## 3. Task Marked + +### Subject + +```text +{{product_name}} notification: a task has been marked +``` + +### Body + +```text +Hello, + +A task has been marked in {{product_name}}. + +Sign in to review the outcome and any next steps: +{{sign_in_url}} + +No mark or result is included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include the mark, grade, result, task name, unit name, +or feedback. + +--- + +## 4. Date Changed + +### Subject + +```text +{{product_name}} notification: a task date has changed +``` + +### Body + +```text +Hello, + +A date associated with a task has changed in {{product_name}}. + +Sign in to confirm the current date: +{{sign_in_url}} + +Use the date displayed in {{product_name}} as the current source of truth. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include the task name, unit name, previous date, or new date. + +--- + +## Key Finding + +The API repository already contains notification mailer functionality and +notification-related email views. Future implementation should investigate +reusing the existing mailer structure instead of creating a separate email +delivery system. + +## Recommended Next Step + +Before production implementation, the Email Notifications team should confirm: + +1. The trigger for each notification. +2. The user roles that receive each notification. +3. The secure destination URL for each email. +4. Whether exact dates may be included in email bodies. +5. Whether emails are sent immediately or through a background job. +6. How notification preferences and opt-out behaviour are applied. + +Production mailer code, event triggers, database changes, and frontend notification settings are outside the scope of this starter documentation task. \ No newline at end of file diff --git a/docs/email_notifications/v2_event_email_templates.md b/docs/email_notifications/v2_event_email_templates.md new file mode 100644 index 0000000000..abdc32bf8f --- /dev/null +++ b/docs/email_notifications/v2_event_email_templates.md @@ -0,0 +1,314 @@ +# V2 Event Email Templates (EN-V05 to EN-V08) + +## Purpose + +These templates provide reader-focused email copy for the remaining four v2 +notification events. They follow the privacy rules in +`safe_starter_email_templates.md`: include only the detail needed to explain +what happened, keep assessment content out of email, and direct the recipient +back to OnTrack for the full context. + +The subjects below are the preferred product copy. The notification mailer +currently uses the shared subject `{{product_name}}: New notification` for +every event; adopting event-specific subjects requires a separate mailer +decision. + +## Shared placeholders + +- `{{product_name}}`: the configured product name, such as OnTrack. +- `{{recipient_first_name}}`: the recipient's first name. +- `{{student_name}}`: the student whose task is waiting for marking. Used only + in the tutor-facing EN-V06 email. +- `{{task_name}}`: the task that is waiting for marking. Used only in EN-V06. +- `{{group_name}}`: the group the student joined or left. +- `{{unit_code}}`: the unit code. +- `{{submitted_at}}`: the portfolio receipt date and time in the project's + campus timezone, falling back to the application timezone. Include both the + timezone abbreviation and numeric UTC offset, for example + `AEST (UTC+10:00)`. +- `{{destination_url}}`: an authenticated OnTrack page for the event. +- `{{notification_settings_url}}`: the recipient's notification settings. + +Never substitute marks, grades, feedback, comment text, or portfolio contents +into these placeholders. EN-V06 is the one narrow third-party exception: the +assigned tutor may see the submitting student's name and task name. Do not name +any other student or third party. + +--- + +## EN-V05: Group membership changed + +Two versions are required because joining and leaving are different messages. +Only the affected student receives either version. + +### Added to a group + +#### Subject + +```text +{{product_name}} notification: you joined a group +``` + +#### Plain text body + +```text +Hi {{recipient_first_name}}, + +You have been added to {{group_name}} in {{unit_code}}. + +Open {{product_name}} to view your current group: +{{destination_url}} + +No other group members are named in this email. + +This service notification is always sent. +``` + +#### HTML body + +```html +

Hi {{recipient_first_name}},

+ +

You have been added to {{group_name}} in {{unit_code}}.

+ +

View your current group in {{product_name}}

+ +

No other group members are named in this email.

+ +

This service notification is always sent.

+``` + +### Removed from a group + +#### Subject + +```text +{{product_name}} notification: your group changed +``` + +#### Plain text body + +```text +Hi {{recipient_first_name}}, + +You are no longer a member of {{group_name}} in {{unit_code}}. + +Open {{product_name}} to view your current group information: +{{destination_url}} + +If you did not expect this change, contact your teaching team. + +This service notification is always sent. +``` + +#### HTML body + +```html +

Hi {{recipient_first_name}},

+ +

You are no longer a member of {{group_name}} in {{unit_code}}.

+ +

View your current group information in {{product_name}}

+ +

If you did not expect this change, contact your teaching team.

+ +

This service notification is always sent.

+``` + +### Privacy note + +Neither version names another student or describes why the membership changed. +The removal copy states the current fact without implying fault or punishment. +The event uses the `general` category, which has no preference gate. + +### Implementation alignment + +EN-V05 merged before this copy document. Its current templates render the +notification message's "added to" or "removed from" wording and do not yet +include the destination link, softer removal sentence, or always-sent footer +above. Merging this document does not silently change that event; EN-V05 needs a +focused template-and-test follow-up to adopt the approved copy exactly. + +--- + +## EN-V06: Task submitted for marking + +This is the only tutor-facing email in this document. Naming the student and +task is necessary so the tutor can identify the work waiting for them. Do not +include the submission, a mark, feedback, or a comment. + +### Subject + +```text +{{product_name}} notification: a task is ready for marking +``` + +### Plain text body + +```text +Hi {{recipient_first_name}}, + +{{student_name}} submitted {{task_name}} for marking in {{product_name}}. + +Open the task to review the submission: +{{destination_url}} + +The submission and any assessment content are not included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### HTML body + +```html +

Hi {{recipient_first_name}},

+ +

{{student_name}} submitted {{task_name}} for marking in {{product_name}}.

+ +

Open the task in {{product_name}}

+ +

The submission and any assessment content are not included in this email.

+ +

+ You can manage your notification preferences in + your profile. +

+``` + +### Privacy note + +The student name and task name are visible only to the assigned tutor. The +subject remains generic and does not identify the student or task. + +--- + +## EN-V07: Portfolio submission received + +This is a receipt for the student who submitted the portfolio. The receipt time +must include an unambiguous timezone. It confirms receipt only; it does not say +that the portfolio is valid, complete, generated, or assessed. + +### Subject + +```text +{{product_name}} receipt: portfolio submission received +``` + +### Plain text body + +```text +Hi {{recipient_first_name}}, + +{{product_name}} received your portfolio submission at {{submitted_at}}. + +Open {{product_name}} to view its current status: +{{destination_url}} + +This receipt confirms when the submission was received. It does not confirm an assessment outcome. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### HTML body + +```html +

Hi {{recipient_first_name}},

+ +

{{product_name}} received your portfolio submission at {{submitted_at}}.

+ +

View the current status in {{product_name}}

+ +

+ This receipt confirms when the submission was received. It does not confirm + an assessment outcome. +

+ +

+ You can manage your notification preferences in + your profile. +

+``` + +### Privacy note + +The email contains the receipt date and time in the project campus timezone +(application timezone fallback), with both an abbreviation and numeric UTC +offset. It contains no portfolio content, marks, feedback, or other student +information. + +--- + +## EN-V08: Discussion or check-in + +### Scope decision required + +OnTrack does not currently have a booking or appointment record that can raise +the event as originally named. `Task#add_discussion_comment` does create an +audio discussion request for a student, so `discussion_request_created` is the +proposed replacement. The copy below is for that replacement and must not be +described as a booking confirmation. The Email Notifications lead must approve +the replacement before its implementation merges. + +If a real booking model is introduced later, write separate copy that includes +the booked date, time, timezone, and location or meeting method from that model. + +### Subject + +```text +{{product_name}} notification: a discussion prompt is ready +``` + +### Plain text body + +```text +Hi {{recipient_first_name}}, + +A discussion prompt is ready for you in {{product_name}}. + +Open the task to listen and respond: +{{destination_url}} + +The prompt is not included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### HTML body + +```html +

Hi {{recipient_first_name}},

+ +

A discussion prompt is ready for you in {{product_name}}.

+ +

Open the task in {{product_name}}

+ +

The prompt is not included in this email.

+ +

+ You can manage your notification preferences in + your profile. +

+``` + +### Privacy note + +The email does not include audio, prompt content, comments, task or unit names, +or another person's name. The proposed replacement uses the `feedback` +category and respects `receive_feedback_notifications`. + +--- + +## Review checklist + +- Each event has a subject, plain text body, and HTML body. +- EN-V05 covers both joining and leaving a group. +- EN-V06 is written to a tutor and identifies only the student and task needed + to act. +- EN-V07 includes a date, time, timezone abbreviation, and numeric UTC offset + and does not overstate what the receipt proves. +- EN-V08 is not presented as buildable booking functionality. +- No template includes marks, grades, feedback, comment text, portfolio + contents, or unnecessary third-party names. diff --git a/docs/notifications/CONTRIBUTING.md b/docs/notifications/CONTRIBUTING.md new file mode 100644 index 0000000000..a3d933dcb3 --- /dev/null +++ b/docs/notifications/CONTRIBUTING.md @@ -0,0 +1,410 @@ +# How to contribute to notifications + +For everyone working on Email Notifications or Mobile Notifications. + +Read this once. It is short. Most of it is here because somebody already lost a +day to it. + +This is not the `CONTRIBUTING.md` at the root of `doubtfire-api`. That one is +upstream Doubtfire's and it is not ours. + +--- + +## The three rules that matter most + +1. **Branch off `feature/notifications`. Open your pull request back into + `feature/notifications`.** Never `11.0.x`. Never `development`. +2. **Paste your pull request link into your Planner ticket.** If you skip this, + your work does not get counted. There is no automatic backup. +3. **If you are stuck for more than about an hour, say so.** Post where you got + stuck. That is not failure, that is the job. Sitting silently stuck helps + nobody and it costs you the ticket. + +--- + +## Before you start a ticket + +Tick the first checklist item on the ticket: **"Confirmed I have started. My +branch name is ______"** and fill in the branch name. + +This is how we know a ticket is being worked on. If that box is empty, anyone +can take the ticket. Ten seconds of your time saves someone else duplicating +your work. + +--- + +## Setting up, and the four things that go wrong + +Full instructions are in `doubtfire-deploy/RUNNING-LOCALLY.md`. Read that first. +The four failures below are worth being able to recognise quickly; that guide +has the current setup commands and the detailed recovery steps. + +**You are probably pointed at the wrong remote.** Our work is in the +`ontrack-features-t2-2026` organisation. It is not on `thoth-tech` and it is not +on `doubtfire-lms`. If `git fetch` cannot find `feature/notifications`, this is +why. + +``` +git remote set-url origin https://github.com/ontrack-features-t2-2026/.git +git fetch origin +``` + +`doubtfire-api` and `doubtfire-web` sit on `feature/notifications`. +`doubtfire-deploy` sits on `11.0.x` and has no notifications branch. + +**A push that fails with 403 is an access problem, not a git problem.** Being a +member of the organisation gives you read only. Write comes from the +`ontrack-contributors` team. Ask the lead and it takes one minute to fix. + +**On Windows, do not put the database on a bind mount.** MariaDB cannot reliably +rename a table across the Windows host share and `db:populate` dies with +`Tablespace is missing for a table`. This is fixed on `11.0.x` in deploy, using a +named `db_data` volume. If you hand edited your compose file to work around it, +undo the edit and pull instead. + +**The branch name your clone shows you can be a lie.** On macOS the filesystem +is case insensitive, so an inherited `Feature/` directory in `.git/refs` swallows +later lowercase `feature/*` refs and `git branch -a` will show you a capitalised +branch that does not exist on the server. Never read a branch name off +`git branch -a` for a pull request. Use `git ls-remote --heads origin`. + +--- + +## Which repository + +Every ticket says which repository it is in. + +| Repo | What it is | +|---|---| +| `doubtfire-api` | The backend. Ruby on Rails | +| `doubtfire-web` | The frontend. Angular | +| `doubtfire-deploy` | Docker and configuration | + +If your ticket says `none`, there is no code. You are writing a document and +attaching it to the ticket. + +--- + +## Branches + +Integration branch: **`feature/notifications`** + +Your branch is named on the ticket. It looks like `email/task-comment` or +`push/opt-in`. + +``` +git checkout feature/notifications +git pull origin feature/notifications +git checkout -b email/task-comment +``` + +Do the work, then: + +``` +git add +git commit -m "feat(notifications): email on new task comment" +git push -u origin email/task-comment +``` + +**Never create a branch underneath a name that is already a branch.** Git cannot +hold both a branch and a folder at the same path and it fails with +`cannot lock ref`. Concretely: no work branch may be named +`feature/notifications/`. Work branches live under `email/` and +`push/`, which can never collide with the integration branch. + +--- + +## Commits + +Format: `type(scope): short summary in the present tense` + +``` +feat(notifications): email on new task comment +fix(profile): stop resetting notification preferences on edit +docs(notifications): audit existing email send sites +test(notifications): cover preference gating +chore(deploy): add mail catcher to local dev stack +``` + +Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`. Keep the summary under +about 50 characters. Use the scope `notifications` unless you are genuinely +touching something else. Every ticket has its commit message already written on +it, so you can copy it. + +--- + +## Pull requests + +Open it against **`feature/notifications`**. Double-check this. GitHub often +defaults to the wrong branch and it is the single most common mistake. +**Check the base repository, not just the branch name.** It must read +`ontrack-features-t2-2026/...`. If it reads `doubtfire-lms/...`, change it. The +upstream maintainer has a branch called `feature/notifications` too, two hops up +the fork network, so the branch name on its own no longer tells you where you +are pointing. + +Your PR description must include: + +``` +Ticket: EN-E01 + +Built against: + doubtfire-api feature/notifications + doubtfire-web feature/notifications + doubtfire-deploy 11.0.x + +What this does: + + +How I tested it: + +``` + +**Reviewers are told to reject pull requests that leave out the built-against +block.** Get each sha with `git rev-parse --short HEAD` in that repository. Yes, +all three, even if you only touched one. It is how a reviewer reproduces what you +saw. + +Keep pull requests small. Everything that merged last trimester was between +about 30 and 130 lines across fewer than ten files. Large pull requests stall, +and they stall for weeks rather than days. + +--- + +## Review + +How many approvals you need depends on what you touched. + +**One approval** if your change only adds new files of your own plus a few lines +in a model. Most event tickets are this. + +**Two approvals** if you touched any of: + +- a database migration +- `db/schema.rb` +- `NotificationService` or `PushNotificationService` +- configuration, a manifest, or the Gemfile +- a file another open ticket is also touching + +If you are not sure, ask. Guessing low wastes a reviewer's time. Guessing high +costs you nothing. + +The lead merges. Do not merge your own pull request, and note that GitHub will +not let you approve it either. + +**Check CI, but do not treat it as the whole review.** API code pull requests run +the Minitest suite and RuboCop in GitHub Actions. Those workflows deliberately +ignore documentation-only changes, so a documentation pull request can have no +checks. Web pull requests run build, lint, typecheck and vitest workflows. A +green result is necessary when those checks apply, but it does not replace the +human review or targeted manual testing. Put your real test output in the pull +request body so a reviewer has something to check rather than a promise. + +**Two approvals is our rule, not GitHub's.** The ruleset enforces one. Do not +treat an available merge button as evidence the rule was met. + +**If you stack your branch on somebody else's unmerged branch, the approval gate +quietly disappears.** Rulesets cover `feature/notifications`, not whatever branch +was cut yesterday. So a pull request targeting a teammate's branch can merge with +zero approvals. Stacking is sometimes the right thing to do, just tell the lead +when you do it, and retarget to `feature/notifications` once the branch below you +lands. + +--- + +## Keeping up to date + +Other people are merging into `feature/notifications` while you work. Before you +open your pull request: + +``` +git checkout feature/notifications +git pull origin feature/notifications +git checkout +git merge feature/notifications +``` + +Fix any conflicts, then push. If a conflict looks frightening, **stop and ask.** +Do not force push. Do not delete files to make the conflict go away. Someone +will help you in five minutes. + +--- + +## Two files that cause conflicts, and how we avoid them + +**`db/schema.rb`.** This is rebuilt automatically every time anyone adds a +migration, and two branches with migrations will always conflict. Only a couple +of tickets have a migration and they are all held by the lead. **If your ticket +does not mention a migration and you find yourself writing one, stop and ask.** +You are probably solving the wrong problem. + +**Event documentation.** Every event gets its own file at +`docs/notifications/events/.md`. Never add to a shared list. If +everyone edited one file, every event ticket would conflict with every other one. + +One more that is not a file. **Leave a newline at the end of every file you +touch.** Prettier enforces it on the web side, and a missing final newline turns +the last line of a shared file into a conflict against every other open pull +request. + +--- + +## The one domain rule: channel delivery belongs in Sidekiq + +`NotificationService.notify` persists the in-app record, then queues separate +ID-only email and push jobs. Sidekiq workers reload the notification and perform +provider network I/O; a request only waits for the short Redis hand-offs. Both +jobs avoid the general-purpose `default` queue: email uses `mailers` and Web Push +uses `notifications`. Every deployed environment that should deliver +notifications must run a Sidekiq worker for both channel queues. + +The hand-off is at-least-once. If either job cannot be queued, `delivered_at` +stays empty so a later event retry can try again. That retry may enqueue the +other channel twice if its first hand-off succeeded before the failure. Channel +jobs must therefore continue to accept only stable ids and tolerate duplicate +delivery. + +Both channel jobs must raise when their notification id is not yet visible. +Producers can run inside wider database transactions, so a fast worker may read +before commit; treating that lookup as a successful no-op permanently loses the +channel. Push provider failures are attempted across all registered browsers +and then raised as an aggregate error so Sidekiq's retry policy is effective. + +**Never loop over a whole cohort and call `NotificationService.notify` directly +from a web request.** Even without provider I/O, that would create one record +and make two queue round trips per recipient before the request can finish. The +current new-task and due-date events avoid that by enqueueing +`NewTaskAvailableNotificationJob` and `TaskDueDateChangedNotificationJob`; group +CSV import suppresses notifications. Follow those current patterns rather than +reintroducing request-path fan-out. + +So before you wire an event to a hook, ask who it reaches when the hook fires in +the worst case, not the normal case. Three separate tickets have hit this +independently. If the answer is "everyone in the unit", inspect the existing +fan-out jobs and talk to the lead before you build it. + +Two related habits worth having: + +- **Never notify somebody about their own action.** Check the actor against the + recipient. +- **Look at every caller of the method you are hooking, not just the obvious + one.** `add_member` looks like a student joining a group. It is also called by + tutorial changes, enrolment deletion and CSV import. + +--- + +## Documentation + +All notification documentation lives in **one** place: +`doubtfire-api/docs/notifications/`. Do not start a new folder, and do not put it +in `doubtfire-web`. + +For general documents, use lowercase, hyphenated names with no dates and one file +per subject: `push-setup.md`, not `PushSetup_2026-08-14.md`. Event documents are +the exception: their filename is the exact lower-snake-case event passed to +`NotificationService.notify`, for example `task_comment_created.md`. + +| What you are writing | Where it goes | +|---|---| +| An event | `docs/notifications/events/.md` | +| Anything else | `docs/notifications/.md` | + +**For an event, copy `docs/notifications/events/_template.md` and fill in the +eight field table.** It is not optional formatting. The table is what lets +somebody read the recipient and the preference gate without opening the code, +and it is what the security review tickets read. + +Worked examples to copy rather than invent: + +- `docs/notifications/events/task_comment_created.md` — the model event doc +- `docs/notifications/events/_template.md` — the eight fields +- `docs/notifications/push-setup.md` — VAPID keys and payloads +- `docs/notifications/testing-push-locally.md` — read this before you try to + test push on a phone + +**Push does not work on a phone over your LAN address.** A phone on your wifi +hitting `http://192.168.x.x:4200` is not a secure context, so the browser hides +the push API entirely and the opt-in button greys out. `localhost` is fine +without HTTPS. A phone is not localhost. You need a tunnel, and +`testing-push-locally.md` has the commands. On iOS there is a second step, you +have to Add to Home Screen and open it from the icon. + +If somebody asks you a question this page does not answer, the answer goes in +here, not just in a reply. + +--- + +## Tests + +Write them. Every code ticket has its tests in the steps. + +- **API:** Minitest, in `test/`, mirroring the `app/` path. So a test for + `app/models/task.rb` goes in `test/models/`. Run inside the container, never + on your own machine. +- **Web:** vitest, in `.spec.ts` beside the component. + +Every Grape endpoint gets a test. Every new Angular component gets a `.spec.ts`. + +The `.rspec` file at the root of the api repository is **dead configuration.** +Ignore it. This project does not use RSpec, and the handover document that says +it does is a trimester out of date. The same document says Angular 17 and Karma. +It is Angular 22 and vitest. + +Development mail goes to **Mailpit, on `http://localhost:8025`**. The dev stack +starts it and sets `DF_SMTP_ADDRESS`, so `config/environments/development.rb` +takes the SMTP path and everything the app sends turns up there. That is the +easiest way to check an email actually went out, and a Mailpit screenshot is +good evidence on a ticket. + +If `DF_SMTP_ADDRESS` is not set, Rails falls back to writing mail to a file +instead. Under Docker those land on the host at +`doubtfire-deploy/data/tmp/mails/`, not under `doubtfire-api/tmp/mails`, because +the compose file mounts `../data/tmp` over `/doubtfire/tmp`. Looking in the wrong +one shows an empty folder and makes email look broken. The comment in +`development.rb` explains this too. + +--- + +## Where things live + +| What | Where | +|---|---| +| Tickets | Microsoft Planner | +| Code | GitHub, `ontrack-features-t2-2026` | +| Evidence and documents | Attached to your Planner ticket | +| Notification documentation | `doubtfire-api/docs/notifications/` | +| How to run the app | `doubtfire-deploy/RUNNING-LOCALLY.md` | +| How to test push on a phone | `docs/notifications/testing-push-locally.md` | + +--- + +## Where your work ends up + +``` +your branch -> feature/notifications lead merges +feature/notifications -> thoth-tech Feature/Notifications Brian Dang merges +thoth-tech -> doubtfire-lms 11.0.x definition of done +``` + +`thoth-tech` has no `Feature/Notifications` branch yet. It has to be created +there before the second hop can happen, and that has been asked for. + +So a pull request you open is two merges away from the real OnTrack project. +That is worth knowing when you decide how much care to put into it. + +--- + +## If you are stuck + +Post in the team channel with: + +1. Your ticket ID +2. What you were trying to do +3. The exact error text, copied and pasted, not described +4. What you already tried + +Asking early is what a good contributor does. Nobody is judging you for it. +Going quiet for a week is the only thing that actually causes a problem. + +And if you are given a command you do not understand, say so before you run it. +That has already found one real bug in our Docker setup. diff --git a/docs/notifications/events/README.md b/docs/notifications/events/README.md new file mode 100644 index 0000000000..adf511dc53 --- /dev/null +++ b/docs/notifications/events/README.md @@ -0,0 +1,52 @@ +# Notification events + +Every notification event gets its own file in this folder. One event, one file, +named after the event. + + docs/notifications/events/task_comment_created.md + docs/notifications/events/task_status_changed.md + docs/notifications/events/extension_granted.md + +The file name is the string passed as `event:` to `NotificationService.notify`. +If the code says `event: 'task_comment_created'` then the file is +`task_comment_created.md`. No other naming, no grouping by category, no folders +inside this one. + +## Why one file each + +Eight event tickets run at the same time. If they all documented their event in +one shared file, every one of them would edit the same lines and every one would +conflict with the other seven. The first to merge wins and the other seven stop +to fix a merge by hand, for a docs change that had nothing to do with anyone +else's work. + +Adding a file conflicts with nothing. Two people can add +`task_status_changed.md` and `extension_granted.md` on the same afternoon and +neither branch touches the other. + +This is the same reason the mailer looks its template up by event name instead +of holding a lookup table. A new event adds +`app/views/notifications_mailer/.html.erb` and `.text.erb` and edits no +existing file. The docs follow the code. + +## Adding one + +1. Copy `_template.md` to `.md`. +2. Fill in the eight fields. Read the code and copy the real values out of it, + do not write down what you think it does. +3. Delete the field guidance and the worked example from your copy. Both are + there to be read once. Carrying them into every event file is the duplication + this folder exists to avoid. + +The underscore on `_template.md` keeps it at the top of the listing and marks it +as not being an event. The worked example inside it is an example and not the +record for that event, so one event one file still holds. Nothing reads this +folder in code, so the underscore is only for people. + +## What this folder is not + +It is not the design of the notification system. That is `NOTIFICATIONS.md` at +the repo root, and it covers the service, the types, the preferences and the +channels. A file in here is the record of one event: what sets it off, who hears +about it, and where to find the line that raises it. Keep the general +explanation out of it, there is one copy of that already. diff --git a/docs/notifications/events/_template.md b/docs/notifications/events/_template.md new file mode 100644 index 0000000000..d065829c67 --- /dev/null +++ b/docs/notifications/events/_template.md @@ -0,0 +1,119 @@ +# Event: + +| Field | Value | +|---|---| +| Event name | | +| Category | | +| What triggers it | | +| Who receives it | | +| Preference that gates it | | +| Email subject | | +| Email body summary | | +| Where it is raised | | + +## What goes in each field + +**Event name.** The exact string passed as `event:` to +`NotificationService.notify`. Lower case with underscores. It is also the file +name of this document and the name of the mailer templates, so get it right +once and it lines up everywhere. + +**Category.** The `type:` argument. One of `task`, `feedback`, `portfolio`, +`extension`, `general`, from `Notification::TYPES` in +`app/models/notification.rb`. The category is what the user's preference +switches on, so pick the one that matches how a user would think about turning +this off. + +**What triggers it.** The thing a person did, in a sentence. Then the method +that runs afterwards. "A tutor saves a text comment" is more use to the next +reader than "the comment callback fires". + +**Who receives it.** The `user:` argument, and how it is worked out. Say plainly +if it can be nil and what happens then. Most of the bugs in this area are a +recipient that was assumed to exist. + +**Preference that gates it.** The user column in +`Notification::PREFERENCE_FOR_TYPE` that the category maps to, spelled out in +full. Write `none, always sent` for `extension` and `general`, which have no +entry there. When the preference is off the notification is dropped on every +channel, the in-app bell included. + +**Email subject.** What lands in the inbox. Every notification shares one +subject built in `NotificationsMailer#single_notification`, so unless you +changed the mailer this is the same line as everyone else's. The product name +at the front is config and not a fixed word, so quote the line that builds it +and say what your stack sets it to. + +**Email body summary.** Two or three lines on what the email tells the reader, +and what it leaves out on purpose. Name the templates. If the event has no +templates of its own say so, the mailer falls back to the generic +`single_notification` pair and the email is much plainer. + +**Where it is raised.** `path/to/file.rb:`, the method it sits in, and +what calls that method. Line numbers move, so name the method too, that is the +part a reader can still find in six months. + +Anything else worth knowing goes below the table under its own headings. +Recipient guards, things left out on purpose, how to check it by hand, the test +file. Keep it short. + +--- + +# Worked example + +This is `task_comment_created`, the first event wired into OnTrack, from ticket +EN-E01. The code it describes lives on `email/task-comment` until that branch +merges, so read the paths below there. Copying the template gives you a copy of +this section and of the guidance above it. Delete both from your own file. + +| Field | Value | +|---|---| +| Event name | `task_comment_created` | +| Category | `feedback` | +| What triggers it | Someone saves a text comment on a task. `Task#add_text_comment` saves the comment and then calls `notify_comment_recipient` | +| Who receives it | `comment.recipient`, set by `add_text_comment` at `task.rb:945` and read here rather than worked out again. The student when a tutor commented. When a student commented it is `Project#tutor_for`, which gives the tutorial's tutor, or the unit's main convenor when there is no tutorial or the tutorial has no tutor | +| Preference that gates it | `receive_feedback_notifications` | +| Email subject | `#{product name}: New notification`, built at `app/mailers/notifications_mailer.rb:22`. `config/institution.yml` defaults the product name to `Doubtfire` and `DF_INSTITUTION_PRODUCT_NAME` overrides it. Our deploy sets `OnTrack`, so the inbox shows `OnTrack: New notification` | +| Email body summary | Greets the user by name, gives one line saying who commented on which task in which unit, then says the comment is not included and to open the task to read it. A link to the task and a line about turning the emails off. Templates are `app/views/notifications_mailer/task_comment_created.text.erb` and `.html.erb` | +| Where it is raised | `app/models/task.rb:971`, in `Task#notify_comment_recipient`, called from `add_text_comment` at line 949 | + +## Notes + +The comment text never goes into the message or the email. The email is a prompt +to come back to OnTrack, not a copy of the conversation. The message is built as +`"#{comment.user.name} commented on #{task_definition.abbreviation} in #{unit.code}."` +and the link is `/projects//dashboard/`. + +Raising a notification must not stop a comment being posted, so +`notify_comment_recipient` rescues `StandardError`, logs it and carries on. That +is a second layer. `NotificationService.deliver_email` already rescues mail +failures on its own. + +`notify_comment_recipient` returns early on a blank recipient. The comment in +the code says that happens when the project has no tutor, which is not right, +`Project#tutor_for` falls back to the main convenor. The guard is cheap +insurance rather than a case anyone has hit, and the test for it passes a nil +recipient in by hand instead of going through `add_text_comment`. + +The subject is generic on purpose. Per-event subjects need a lookup that every +event ticket would have to edit, which is the collision this folder exists to +avoid. It is a known limitation, left as it is for now. + +## How to check it by hand + +1. Sign in as a tutor, open a student's task and post a comment. +2. Development mail is written to a file, and `development/docker-compose.yml` + mounts `../data/tmp` over `/doubtfire/tmp`, so it lands on the host under + `doubtfire-deploy/data/tmp/mails/`. The file is named after the recipient's + address and every email to that address is appended to the same one. Open the + student's file and read the last message. It names the commenter and the task + and does not contain the comment text. +3. Turn that student's feedback notifications off in their profile and comment + again. Nothing is appended. Do not go looking for a new file, there is only + ever the one per address. + +## Tests + +`test/models/notification_task_comment_test.rb`. Both directions, the preference +switch, the missing recipient, the comment text staying out of the email, and a +notification failure still leaving the comment saved. diff --git a/docs/notifications/events/discussion_request_created.md b/docs/notifications/events/discussion_request_created.md new file mode 100644 index 0000000000..c991bb1585 --- /dev/null +++ b/docs/notifications/events/discussion_request_created.md @@ -0,0 +1,85 @@ +# Event: discussion_request_created + +| Field | Value | +|---|---| +| Event name | `discussion_request_created` | +| Category | `feedback` | +| What triggers it | A tutor raises an audio discussion request for a student's task. `Task#add_discussion_comment` saves the request and every audio attachment before notifying. | +| Who receives it | The student whose task owns the discussion (`discussion.recipient`, set to `project.student`). | +| Preference that gates it | `receive_feedback_notifications` | +| Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | +| Email body summary | Tells the student that a discussion prompt is ready and links back to the task. Audio and prompt content are omitted. Templates are `app/views/notifications_mailer/discussion_request_created.text.erb` and `.html.erb`. | +| Where it is raised | `app/models/task.rb`, in `Task#add_discussion_comment`, after the discussion record and all prompt attachments have been saved | + +## Product decision requested for EN-V08 + +EN-V08 was named "Email when a discussion or check-in is booked". OnTrack has +no booking, appointment, or calendar record for a discussion or check-in, so +there is no truthful booking event to raise. + +This implementation proposes the closest existing action with the same user +intent: a tutor creating an audio discussion request through +`Task#add_discussion_comment`. The event is named +`discussion_request_created` to distinguish it from the existing +`DiscussionPrompt` model and its task-definition prompt-management API. The +email says that a prompt is ready and never claims that a meeting was booked. + +The Email Notifications lead should confirm this replacement event before the +PR merges. If the replacement is rejected, close the PR without merging it. + +`Task#add_discussed_comment` and `Task#add_checked_in_comment` are not hook +points. They record that an interaction already happened, in the past tense. + +## Notification fields + +- Type: `feedback` +- Event: `discussion_request_created` +- Recipient: `project.student` +- Message: `A discussion prompt is ready for you.` +- Link: `/projects/:project_id/dashboard/:task_abbreviation` +- Preference: `receive_feedback_notifications` + +The message deliberately leaves out the tutor's name, task name, unit, audio, +and prompt content. The task route is used so the student can act without +searching for the prompt. + +## Delivery timing and failure handling + +The notification is raised only after every uploaded prompt has been accepted, +converted, and attached. A rejected or failed attachment therefore does not +produce a premature email. A request with multiple audio prompts still produces +one notification after the final attachment succeeds. + +`Task#notify_discussion_request_recipient` rescues notification errors so an +email or notification failure cannot undo an already-created discussion +request. `NotificationService` separately handles email-channel failures. + +## Preference behaviour + +The proposed replacement is categorised as `feedback`, matching the existing +tutor-to-student `task_comment_created` event. A student who turns off feedback +notifications receives no in-app, email, or push notification for this event. + +The original EN-V08 ticket suggested `general`, which would always deliver and +could not be opted out of. That always-on behaviour also required a lead +decision, so the proposal uses the safer existing preference until product +owners decide otherwise. + +## How to check it by hand + +1. As a tutor, open a student's task and create an audio discussion prompt. +2. Confirm the student receives exactly one notification and one email after + every prompt upload completes. +3. Confirm the email links to the task but does not contain the audio, prompt + content, task or unit name, or tutor name. +4. Repeat with feedback notifications turned off and confirm nothing is sent. +5. Confirm marking a task as discussed or checked in does not send this event. + +## Tests + +`test/models/notification_discussion_request_test.rb` + +The tests cover a real valid audio attachment, one notification for multiple +audio prompts, no notification after a failed attachment, recipient, event and +category, feedback preference gating, push link, event-specific email copy, +assessment-content omissions, and failure isolation. diff --git a/docs/notifications/events/extension_assessed.md b/docs/notifications/events/extension_assessed.md new file mode 100644 index 0000000000..bb757e955a --- /dev/null +++ b/docs/notifications/events/extension_assessed.md @@ -0,0 +1,35 @@ +# Event: extension_assessed + +| Field | Value | +| --- | --- | +| Event name | `extension_assessed` | +| Category | `extension` | +| What triggers it | A tutor or the automatic extension flow assesses an extension request through `ExtensionComment#assess_extension`. | +| Who receives it | `project.student`. The notification is only raised after the assessment is successfully saved. | +| Preference that gates it | `none, always sent` | +| Email subject | `#{product name}: New notification`, built by `NotificationsMailer#single_notification`. | +| Email body summary | Tells the student whether the extension was granted or rejected. A granted notification includes the updated due date. The event uses `extension_assessed.html.erb` and `extension_assessed.text.erb`. | +| Where it is raised | `app/models/comments/extension_comment.rb`, in `ExtensionComment#assess_extension`, after `save!`. | + +## Failure behaviour + +No notification or email is sent when: + +- the extension request was already assessed; +- the deadline prevents an extension from being granted; or +- `Task#grant_extension` does not successfully apply the extension. + +A failed `grant_extension` call also leaves the extension request unassessed so that the system does not record or communicate a false successful result. + +## Tests + +`test/models/notification_extension_test.rb` + +The tests cover: + +- granted extensions and the updated due date; +- rejected extensions; +- already-assessed requests; +- deadline failures; +- a failed `grant_extension` operation; +- HTML and text event-specific templates. \ No newline at end of file diff --git a/docs/notifications/events/group_membership_changed.md b/docs/notifications/events/group_membership_changed.md new file mode 100644 index 0000000000..19c9827764 --- /dev/null +++ b/docs/notifications/events/group_membership_changed.md @@ -0,0 +1,62 @@ +# Event: group_membership_changed + +| Field | Value | +|---|---| +| Event name | `group_membership_changed` | +| Category | `general` | +| What triggers it | A student's direct group membership changes through `Group#add_member` or `Group#remove_member`. Internal tutorial-switch operations and bulk CSV imports are intentionally suppressed. | +| Who receives it | Only the student whose membership changed (`project.student`). Other members of the group are not notified. This recipient scope was confirmed with the Email Notifications lead. | +| Preference that gates it | none, always sent | +| Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | +| Email body summary | Tells the affected student that they were added to or removed from a group. The membership change is not broadcast to other group members. Templates are `app/views/notifications_mailer/group_membership_changed.text.erb` and `group_membership_changed.html.erb` | +| Where it is raised | `app/models/group.rb:154` in `Group#add_member` and `app/models/group.rb:175` in `Group#remove_member`. Both call the private `notify_group_membership_change` helper at line 187 | + +## Recipient scope + +The agreed scope is **student only**. + +Only the student who was added to or removed from the group receives the notification. Other group members are not notified because a removal should not be broadcast to the group, and notifying the whole group would multiply the number of sends for a single membership change. + +## Tutorial switch guard + +`Group#switch_to_tutorial` temporarily removes and re-adds members while moving the group to another tutorial. These internal membership operations do not represent a real group membership change and must not send a leave-then-join notification pair. + +## Bulk CSV import guard + +`Unit#import_student_groups_from_csv` may add many students in one request. It calls `Group#add_member(..., notify: false)` so the import does not create and deliver one notification for every CSV row. + +If bulk-import notifications are required later, they should be queued or batched after a successful import rather than delivered separately inside the import request. + +## Implementation + +The event uses: + +- `type: 'general'` +- `event: 'group_membership_changed'` +- recipient: `project.student` +- `link: "/projects/#{project.id}/groups"` + +Event-specific HTML and text email templates are provided under `app/views/notifications_mailer/`. + +## How to check it by hand + +1. Use a unit with Group Work enabled and an existing student project. +2. Add the student to a group. +3. Open Mailpit at `http://localhost:8025` and confirm that one email is sent to the affected student. +4. Remove the same student from the group and confirm that one removal email is sent. +5. Confirm that no other group members receive the notification. +6. Move the group to another tutorial and confirm that the temporary remove/add operations do not create a leave-then-join email pair. + +## Tests + +`test/models/notification_group_test.rb` + +The tests cover: + +- adding a member sends one notification to the affected student +- removing a member sends one notification to the affected student +- other group members are not notified +- `switch_to_tutorial` does not send a leave-then-join notification pair +- a notification failure does not stop the membership change +- bulk CSV imports add students without raising per-student notifications +- the push payload points to the affected project's group page diff --git a/docs/notifications/events/new_task_available.md b/docs/notifications/events/new_task_available.md new file mode 100644 index 0000000000..ae699a1644 --- /dev/null +++ b/docs/notifications/events/new_task_available.md @@ -0,0 +1,139 @@ +# New Task Available Notification + +## Event + +`new_task_available` + +## Purpose + +Notifies eligible students when a newly created task becomes available. + +## Trigger + +The notification fan-out is queued after a task definition is successfully +created through any supported workflow: + +- the normal task-definition API; +- CSV task import; or +- task copying during unit rollover. + +These workflows enqueue only after the task definition is fully populated. A +general `TaskDefinition` `after_create` callback is intentionally not used +because CSV import and rollover save intermediate records before their full +workflow is complete. + +`SendNewTaskAvailableNotificationsJob` also checks active units once a day. +It sends on or shortly after the student's effective start date, covering task, +target-grade and student-specific future dates without creating missing `Task` +rows. A seven-day catch-up window tolerates short worker outages and late +enrolment without announcing historical tasks. + +A tracking timestamp marks definitions that are ready for this sweep. New +application code leaves the marker empty until an explicit workflow completes; +the database supplies a UTC marker only for writes from an older process during +a rolling deployment. The sweep gives those compatibility rows an hour to +settle before evaluating them. + +## Notification + +- Type: `task` +- Event: `new_task_available` +- Recipient: eligible students enrolled in the unit +- Preference: `receive_task_notifications` + +`NotificationService` applies the existing task-notification preference before creating and delivering the notification. + +## Recipient eligibility + +A student receives the notification only when all of the following are true: + +- The task was created through a supported direct, copy/rollover or import workflow. +- The unit is active. +- The student is currently enrolled in the unit. +- The task applies to the student's target grade. +- The student's effective task start date is now or earlier for an immediate + creation fan-out, or within the scheduled release check's bounded window. +- The student has task notifications enabled. + +The student's effective start date is determined with +`Webcal.start_date_for_task_definition`, the same calculation used by the +student calendar. Flexible dates, target-grade dates and supported +student-specific date adjustments are therefore respected. + +## Fan-out + +Notification delivery is not performed directly inside the API request. + +After a task-definition creation workflow completes, it enqueues: + +`NewTaskAvailableNotificationJob` + +The job processes enrolled projects in batches and sends one notification to +each eligible student whose task is already available. The scheduled release +job processes future start dates in daily batches. + +Duplicate notifications for the same student and task are prevented by an +immutable task-definition key backed by a unique database index. Renaming a +task does not resend it, while a genuinely new definition that reuses an old +abbreviation can still notify. Delivery is serialized on the notification row, +outside the student's project lock, so normal retries do not duplicate a +completed delivery. External email and push remain at-least-once: a process +failure after a provider accepts a message but before completion is recorded +can repeat that external message on retry. + +## Email templates + +HTML: + +`app/views/notifications_mailer/new_task_available.html.erb` + +Plain text: + +`app/views/notifications_mailer/new_task_available.text.erb` + +## Link + +The notification links the student to the new task on their project dashboard: + +`/projects/:project_id/dashboard/:task_abbreviation` + +## Future and bulk-created tasks + +Future-dated tasks are not announced early. The scheduled release job notifies +each student on or shortly after their effective start date. Unit rollover and +CSV import enqueue only after their multi-step workflow completes, so a worker +cannot observe a partially populated task definition. Updated CSV rows do not +generate a new-task notification. + +All paths use the same event, preference gate and duplicate guard. A bulk import +may deliberately enqueue one cohort fan-out per newly created task, but none of +that email delivery happens in the API request. + +## Tests + +Automated tests are located at: + +`test/models/notification_new_task_test.rb` + +The tests cover: + +- notification for an eligible student +- fan-out to multiple eligible students +- task-notification preference disabled +- unenrolled students +- task target-grade eligibility +- inactive units +- future base, target-grade and student-specific start dates +- rollover/copy and new CSV-import rows +- no notification for updated CSV rows +- no `Task` rows created by the scheduled sweep +- immutable database deduplication and retry state +- rolling-deployment tracking compatibility + +Test command: + +`bundle exec rails test test/models/notification_new_task_test.rb` + +The focused tests are also in: + +`test/sidekiq/send_new_task_available_notifications_job_test.rb` diff --git a/docs/notifications/events/portfolio_received.md b/docs/notifications/events/portfolio_received.md new file mode 100644 index 0000000000..dd50f21012 --- /dev/null +++ b/docs/notifications/events/portfolio_received.md @@ -0,0 +1,87 @@ +# Event: portfolio_received + +| Field | Value | +|---|---| +| Event name | `portfolio_received` | +| Category | `portfolio` | +| What triggers it | A student successfully starts a new manual portfolio submission through `PUT /projects/:id` with `compile_portfolio: true`. A repeated request while the same manual submission is already pending is not a new submission. | +| Who receives it | The submitting student (`project.student`). | +| Preference that gates it | `receive_portfolio_notifications` | +| Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | +| Email body summary | Confirms the date and time that the portfolio submission was received, including its timezone and UTC offset, and links to the project's current status. It says explicitly that the receipt does not confirm an assessment outcome. No portfolio contents, marks, grades, feedback, or other student information are included. Templates are `app/views/notifications_mailer/portfolio_received.text.erb` and `portfolio_received.html.erb`. | +| Where it is raised | `app/api/projects_api.rb`, in the `PUT /projects/:id` `compile_portfolio` branch, after the new submission state and `portfolio_submission_date` have been saved. | + +## Existing portfolio emails are different events + +The existing email audit records `PortfolioEvidenceMailer#portfolio_ready` and +`PortfolioEvidenceMailer#portfolio_failed`. Those messages are raised later by +`submission:generate_pdfs` after portfolio generation succeeds or fails. + +`portfolio_received` is the earlier receipt for accepting the student's +submission. It uses the shared `NotificationService` email path and does not +call either legacy portfolio mailer. One accepted submission therefore sends +one receipt, while a later generation result remains a separate event. + +## New-submission guard + +A receipt is raised only when a new manual submission is accepted: + +- `compile_portfolio` changes from false to true; or +- a pending auto-generated portfolio is replaced by the student's manual + submission. + +Retrying `compile_portfolio: true` while the same manual submission is already +pending does not create another notification and does not replace the original +`portfolio_submission_date`. Setting `compile_portfolio: false` does not send a +receipt. Once generation has finished and the flag is false again, a later +manual resubmission is new and receives its own receipt. + +The decision and save happen while holding a row lock on the project, so two +concurrent retries cannot both observe the submission as new. + +## Receipt time + +The saved `project.portfolio_submission_date` is the source of truth. It is +rendered in the project's campus timezone. When the project has no campus +timezone, the application timezone is used. The email includes the local date, +time, timezone abbreviation and numeric UTC offset, for example: + +`23 August 2026 at 10:34 PM AEST (UTC+10:00)` + +This is receipt metadata only. The notification and email never include the +portfolio, an assessment result, marks, grades, feedback or rationale. + +## Implementation + +The event uses: + +- `type: 'portfolio'` +- `event: 'portfolio_received'` +- recipient: `project.student` +- `link: "/projects/#{project.id}/dashboard"` + +`NotificationService` applies `receive_portfolio_notifications` before it +creates the in-app notification or delivers email and push. A failure while +raising the notification is logged without rejecting the already accepted +portfolio submission. + +## How to check it by hand + +1. Sign in as a student and submit a portfolio. +2. Confirm that exactly one receipt appears in Mailpit and that it is addressed + to the submitting student. +3. Confirm that the receipt contains the saved date, time, timezone and UTC + offset, and contains no portfolio or assessment content. +4. Repeat the same request while generation is pending and confirm that no + second receipt appears. +5. Turn off portfolio notifications, submit again after generation has + completed, and confirm that no notification or email is created. + +## Tests + +`test/models/notification_portfolio_test.rb` + +The focused tests cover the recipient, event/type, generic subject, push link, +event-specific copy, timestamp and privacy boundary, portfolio preference, +duplicate-request guard, later resubmission, manual replacement of an +auto-generated portfolio, cancellation, and notification failure isolation. diff --git a/docs/notifications/events/task_comment_created.md b/docs/notifications/events/task_comment_created.md new file mode 100644 index 0000000000..cd2b208474 --- /dev/null +++ b/docs/notifications/events/task_comment_created.md @@ -0,0 +1,81 @@ +# Event: task_comment_created + +The first notification event wired into OnTrack. Ticket EN-E01. + +This is the worked example. If you are adding an event, copy the shape of this +one. + +## What it does + +Someone posts a text comment on a task. The other party is told. + +- A tutor comments, the student is emailed. +- A student comments, the tutor is emailed. + +## Where it is raised + +`app/models/task.rb`, in `notify_comment_recipient`, called at the end of +`add_text_comment` once the comment has saved. + + NotificationService.notify( + user: comment.recipient, + type: 'feedback', + event: 'task_comment_created', + message: "#{comment.user.name} commented on #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + +## Fields + +| Field | Value | +|---|---| +| `type` | `feedback`, so the recipient's `receive_feedback_notifications` switch controls it | +| `event` | `task_comment_created` | +| `message` | Who commented, which task, which unit. Never the comment text | +| `link` | `/projects//dashboard/` | + +## Templates + +- `app/views/notifications_mailer/task_comment_created.text.erb` +- `app/views/notifications_mailer/task_comment_created.html.erb` + +`NotificationsMailer#single_notification` picks the template named after the +event when it exists, and falls back to `single_notification.*.erb` when it does +not. That is why adding an event never requires editing the mailer. + +## Three things to know before you copy this + +1. **Do not work out who to notify.** `comment.recipient` is already set by + `add_text_comment`: the tutor when a student commented, the student when a + tutor commented. + +2. **Guard for no recipient.** A project with no tutor for the task definition + has no recipient. `notify_comment_recipient` returns early. Without that it + raises. + +3. **A notification must never break the thing that triggered it.** The call is + wrapped in a `rescue StandardError` that logs and swallows. Posting a comment + must succeed even if notifying fails. + +## How to check it by hand + +1. Sign in as a tutor, open a student's task, post a comment. +2. A file appears in `doubtfire-deploy/data/tmp/mails/`, addressed to the + student. It names the commenter and the task, and does not contain the + comment text. +3. Turn that student's feedback notifications off in their profile, comment + again, and no new file appears. + +## Tests + +`test/models/notification_task_comment_test.rb` + +Covers both directions, the preference switch, the absent recipient, the comment +text staying out of the email, and that a notification failure still leaves the +comment saved. + +## Known limitation + +The email subject is the generic "New notification". Per-event subjects would +need a shared lookup in the mailer, which would make every event ticket edit the +same file and collide. Left as it is on purpose. diff --git a/docs/notifications/events/task_due_date_changed.md b/docs/notifications/events/task_due_date_changed.md new file mode 100644 index 0000000000..b2c19687ec --- /dev/null +++ b/docs/notifications/events/task_due_date_changed.md @@ -0,0 +1,101 @@ +# Event: task_due_date_changed + +## Purpose + +Notify eligible students when a convenor changes a task definition's due date +through the normal task-definition update API. + +## Trigger + +The trigger is in `app/api/task_definitions_api.rb`. + +Immediately after `task_def.update!(task_params)`, the API captures +`saved_change_to_due_date`. After the rest of the update succeeds, it enqueues +`TaskDueDateChangedNotificationJob`. + +A `TaskDefinition` model callback is deliberately not used. Task definitions +can also be saved by unit date propagation, imports, rollovers, copies and +internal maintenance. A model callback could therefore create unexpected +cohort-wide email fan-out. + +## Queue + +The API request does not perform the cohort email and push fan-out directly. +`TaskDueDateChangedNotificationJob` performs the fan-out through Sidekiq. + +A functioning Sidekiq worker must consume the same +`DF_REDIS_SIDEKIQ_URL` used by the API. + +## Recipient eligibility + +A project is eligible only when: + +- the unit is active; +- the project is enrolled; +- the project's target grade is at least the task definition's target grade; + and +- the student has task notifications enabled. + +Recipients are selected from projects rather than existing Task rows. OnTrack +creates Task rows on demand, so an eligible student may not yet have one. The +notification job does not create Task rows. + +## Stale jobs and duplicate queue entries + +The job receives: + +- the task definition ID; +- the previous stored due date; and +- the new stored due date. + +Before sending, it checks that the task definition still has the queued new raw +due date. This prevents an outdated job from sending after the due date has +changed again. + +Sidekiq uniqueness rejects another pending or executing job with the same task +definition ID and old/new date values. + +Automatic retries are disabled because retrying a partly completed cohort +fan-out could create duplicate notifications for students already processed. +A failure for one project is logged without stopping the remaining projects. + +## Notification fields + +- Type: `task` +- Event: `task_due_date_changed` +- Message: names the task and unit but does not expose the due date +- Link: `/projects/:project_id/dashboard/:task_abbreviation` +- Preference: `receive_task_notifications` + +## Bulk unit date changes + +Changing a unit start date updates many task definitions internally. This +implementation does not send one email per changed task for that path. + +A future unit-level notification or digest should cover bulk schedule changes +without sending many separate emails to each student. + +## Templates + +- `app/views/notifications_mailer/task_due_date_changed.text.erb` +- `app/views/notifications_mailer/task_due_date_changed.html.erb` + +## Tests + +- `test/sidekiq/task_due_date_changed_notification_job_test.rb` +- `test/api/units/task_definitions_api_test.rb` + +The tests cover: + +- eligible students without Task rows; +- target-grade filtering; +- withdrawn students; +- notification preferences; +- inactive units; +- stale jobs; +- privacy-safe message content and links; +- the event-specific email template; +- enqueue on a due-date API update; +- no enqueue for unrelated updates; +- no enqueue from direct model updates; and +- queue failure not breaking the core due-date update. \ No newline at end of file diff --git a/docs/notifications/events/task_due_soon.md b/docs/notifications/events/task_due_soon.md new file mode 100644 index 0000000000..ceea92f707 --- /dev/null +++ b/docs/notifications/events/task_due_soon.md @@ -0,0 +1,175 @@ +# Event: task_due_soon + +## Purpose + +Remind a student that a task they still owe is nearly due, so a deadline is not +the first they hear about it. + +## Trigger + +Nothing a person does. Every other event in this folder hangs off an action: +a comment is posted, a due date is edited, a status changes. A deadline getting +closer is nobody doing anything, so there is no model to hook and no callback to +add. The reminder has to be swept for. + +`SendDueSoonRemindersJob` does the sweep. `config/schedule.yml` runs it under the +name `send_due_soon_reminders`. + +## Schedule + +`every day at 8am`. + +Once a day, because a deadline only moves once a day and every notification this +job raises sends an email. Sweeping every thirty minutes would find the same +answer forty-eight times. + +At a fixed time, because that also fixes when the emails land. On a short +interval a student gets theirs at whatever hour their task happened to cross +into the window, which is 3am as often as any other hour. Pinning it to the +morning makes the reminder arrive on a day somebody can act on it. + +Missing a run costs nothing. The job asks whether a task is due within the next +three days, not whether it crossed a line since the last run, so tomorrow's run +still catches everything a skipped run would have. + +**Which 8am depends on the environment.** There is no timezone in the cron +expression, no `config.time_zone` in the app, and sidekiq-cron reads the process +clock, so the hour comes from `TZ`. `development/api.env` sets +`TZ=Australia/Melbourne`, which is what makes this a morning. A deployment that +leaves `TZ` unset gets the image default of UTC and sends these in the evening +local time. `aggregate_task_completion_stats`, at 11:55pm, already depends on the +same thing. + +**This has not been seen to fire.** There is no Sidekiq worker in the dev stack, +which is EN-F03, so the schedule entry is unverified beyond the assertion in +`test/sidekiq/scheduled_job_test.rb` that it loads and enqueues. + +## Window + +Three days, `SendDueSoonRemindersJob::WINDOW_DAYS`. + +Long enough to still do something about it over a weekend, short enough that the +reminder is about this task and not about the rest of the trimester. +`Project#top_tasks` calls seven days "soon" for its own purposes, and seven days +of warning on a weekly task is most of the tasks a student has, which is a list +rather than a reminder. + +## Recipient eligibility + +A project gets a reminder about a task definition only when: + +- the unit is active; +- the project is enrolled; +- the project has a target grade set; +- the task definition is assigned at that target grade; +- the task's status is one of `not_started`, `working_on_it`, `need_help`, + `fix_and_resubmit` or `redo`; and +- the student has task notifications enabled, which `NotificationService` + enforces rather than this job. + +Recipients come from projects and not from Task rows. OnTrack creates a Task row +the first time anyone touches the task, so the students who have not started +have no row, and they are the ones a reminder is for. + +**Nothing in this job may call `Project#task_for_task_definition`**, which +creates the row it cannot find, and nothing may call +`Project#task_definitions_and_status` either, because that calls it. The job +reads `project.tasks` once per project and looks each definition up in a hash. + +That is also why it is affordable. `task_definitions_and_status` asks for the +assigned definitions per project and then runs two more queries per definition, +so a five hundred student unit with twenty task definitions is upwards of twenty +thousand queries. Here the definitions are read once per unit and the tasks once +per project. + +### Statuses deliberately left out + +`discuss` and `demonstrate` both mean the student has submitted and is waiting on +a tutor. Telling them the task is due soon is wrong, and it is the kind of wrong +that teaches people to stop reading notifications, so `OUTSTANDING_STATUSES` +lists only the five that mean work is still owed. A project with no Task row at +all counts as outstanding. + +## Which deadline + +Per student, not per task definition, and answered by +`Webcal.end_date_for_task_definition(task_definition, task, project)`. + +That method already exists, already handles both cases, and is what the +student's calendar feed shows them, so writing the rule again here would mean +two answers to "when is this due" that could disagree. + +- With a Task row, `Task#local_due_date`, which knows about extensions and about + a unit's flexible dates. +- Without one, the grade level override when the unit has flexible dates, and + the task definition's own `target_date` otherwise. + +The second case is the one that is easy to get wrong. On a unit with flexible +dates the grade override applies before any Task row exists, so a grade 2 +student can be due days away from the unit's own date without ever having opened +the task. `Project#top_tasks` reads `target_date` directly and has this gap; +`Webcal` does not, which is why this follows `Webcal`. + +## Duplicates + +One reminder per student per task, ever. Before notifying, the job asks: + +```ruby +Notification.exists?(user_id:, notification_type: 'task', event: 'task_due_soon', link:) +``` + +The index `index_notifications_on_user_id_and_event` is what makes that cheap +enough to ask once per candidate task. Without the guard the job runs again +tomorrow, the task is still due soon tomorrow, and the same student is emailed +every morning until the deadline passes. + +Known consequence: a task whose deadline is later extended past the window and +then comes back into it does not produce a second reminder. That is deliberate. +The student asked for the extension, so they know about the task, and +`task_due_date_changed` covers a convenor moving it. + +## Notification fields + +- Type: `task` +- Event: `task_due_soon` +- Message: `" in is due soon."` +- Link: `/projects/:project_id/dashboard/:task_abbreviation` +- Preference: `receive_task_notifications` + +The date stays out of the message, the same as `task_due_date_changed`. The row +outlives the deadline it describes, so "due on the 14th" is wrong a week later +while "due soon" only stops being interesting. + +## Failure handling + +A failure on one project is logged, its id is collected, and the sweep carries on +through the rest of the cohort. At the end of `perform` the collected ids are +raised, which is what `NewTaskAvailableNotificationJob` does and for the same +reason: logging and returning normally would leave `perform` successful, Sidekiq +would schedule no retry, and a student whose task is due today is filtered out as +overdue tomorrow, so that reminder is gone for good. + +`retry: 1`. Re-running the whole sweep is only safe because of the duplicate +guard, which skips everyone already reminded. + +Sidekiq uniqueness rejects a second copy of the sweep, +`lock: :until_executed` on a fixed lock argument. + +## Templates + +- `app/views/notifications_mailer/task_due_soon.text.erb` +- `app/views/notifications_mailer/task_due_soon.html.erb` + +## Tests + +`test/sidekiq/send_due_soon_reminders_job_test.rb`, seventeen cases, covering +eligible students without Task rows, both edges of the window, an already passed +deadline, the duplicate guard, withdrawn students, inactive units, the +notification preference, target grade filtering, a student's own extended +deadline, a flexible unit's grade level deadline with no Task row, a task +waiting on a tutor, a failure being raised rather than swallowed, privacy-safe +content, and the event template. + +`test/sidekiq/scheduled_job_test.rb` covers the schedule entry loading and +enqueuing. It counts the entries in `config/schedule.yml`, so adding this one +changed the expected count from six to seven. diff --git a/docs/notifications/events/task_status_changed.md b/docs/notifications/events/task_status_changed.md new file mode 100644 index 0000000000..a579342373 --- /dev/null +++ b/docs/notifications/events/task_status_changed.md @@ -0,0 +1,108 @@ +# Event: task_status_changed + +A staff member changes the status of a task. The student is told. Ticket EN-E02. + +Built the same way as the worked example in `task_comment_created.md`; read that +one first. + +## What it does + +A tutor marks a task, and the student whose task it is gets an email telling them +the status changed. + +- A tutor changes the status, the student is emailed. +- A student changing their own task is not emailed about their own action. + +## Where it is raised + +`app/models/task.rb`, in `notify_student_of_status_change`, called at the end of +`trigger_transition` once the transition has succeeded and the new status has +been saved. + + NotificationService.notify( + user: project.student, + type: 'task', + event: 'task_status_changed', + message: "#{by_user.name} updated the status of #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + +## Fields + +| Field | Value | +|---|---| +| `type` | `task`, so the student's `receive_task_notifications` switch controls it | +| `event` | `task_status_changed` | +| `message` | Who acted, which task, which unit. Never the new status value | +| `link` | `/projects//dashboard/` | + +## Templates + +- `app/views/notifications_mailer/task_status_changed.text.erb` +- `app/views/notifications_mailer/task_status_changed.html.erb` + +`NotificationsMailer#single_notification` picks the template named after the +event when it exists. Adding this event never required editing the mailer. + +## Three things to know before you copy this + +1. **Only a staff action notifies.** The guard is `role == :tutor`. A student + changing their own task (submitting, working on it) must never email + themselves. `role` is already worked out at the top of `trigger_transition`. + +2. **Only a real change notifies.** The status before the transition is captured + and compared at the end. Re-applying the same status is a no-op and sends + nothing. + +3. **A notification must never break the transition.** The call is wrapped in a + `rescue StandardError` that logs and swallows. Marking a task must succeed + even if notifying fails. + +## How to check it by hand + +1. Sign in as a tutor, open a student's task, change its status. +2. An email to the student arrives at http://localhost:8025 (Mailpit). It names + the tutor and the task, and does not contain the new status value. +3. Sign in as that student, change one of their own tasks, and confirm no email + is sent to themselves. +4. Turn that student's task notifications off in their profile, have the tutor + mark again, and no email arrives. + +## Known limitations and deliberate choices + +- **Bulk marking queues one email and one push job per task.** `Project#trigger_week_end` + (`app/models/project.rb`) loops `trigger_transition(trigger: 'complete', + bulk: true)` over a student's discuss/demonstrate tasks, and this event ignores + the `bulk:` flag, so a single request can enqueue several near-identical + notifications. Provider delivery occurs in Sidekiq, but the queue hand-offs + still happen in that request. The path is latent today — no + `doubtfire-web` caller drives `trigger_week_end`. Left un-suppressed on purpose + so bulk-marked tasks still notify; batching many into one email belongs with the + event design, not here. + +- **The fix-and-resubmit cascade does not raise this event.** Inside `assess`, the + `recursive_fix` cascade calls `assess` directly on dependent tasks instead of + going through `trigger_transition`, so those status changes raise nothing here. + The student is still emailed, but via `task_comment_created` (the cascade adds an + automated comment) — i.e. under a different event. Reconciling that is out of + scope for EN-E02. + +- **Site admins acting on the student-side branches notify nobody.** `user_role` + returns `:admin` for an unenrolled site admin, who can still drive the + `working_on_it` / `need_help` / `not_started` / `ready_for_feedback` branches. + The `role == :tutor` guard means those changes notify no one. That is intended: + an admin poking at a task is not a tutor marking it for the student. + +## Tests + +`test/models/notification_task_status_test.rb` + +Covers the staff change, the student's own action sending nothing, an unchanged +status sending nothing, the preference switch, the status value staying out of +the email, the event-specific template being used, a bulk mark still notifying, +and that a notification failure still leaves the transition committed. + +Not yet covered: the group-task fan-out (each member emailed about their own +task). The behaviour is correct — `propagate_transition` threads `by_user` +through a per-member `trigger_transition` — but a factory-built group task test +is a worthwhile follow-up. diff --git a/docs/notifications/events/task_submitted.md b/docs/notifications/events/task_submitted.md new file mode 100644 index 0000000000..4d9bca7928 --- /dev/null +++ b/docs/notifications/events/task_submitted.md @@ -0,0 +1,63 @@ +# Event: task_submitted + +| Field | Value | +|---|---| +| Event name | `task_submitted` | +| Category | `task` | +| What triggers it | A student submits a task and it genuinely moves into the ready-for-feedback (ready-for-marking) state through `Task#trigger_transition`. | +| Who receives it | `project.tutor_for(task_definition)`, the tutor responsible for that task. A missing tutor is guarded and raises no notification. | +| Preference that gates it | The recipient tutor's `receive_task_notifications` preference. | +| Email subject | `#{product name}: New notification`, built by `NotificationsMailer#single_notification`. The generic subject deliberately does not identify the student or task. | +| Email body summary | Names the student and task so the assigned tutor knows what is waiting. The submission and all assessment content are deliberately omitted. Templates are `app/views/notifications_mailer/task_submitted.text.erb` and `.html.erb`. | +| Where it is raised | `app/models/task.rb`, in `Task#notify_tutor_of_task_submission`, called at the end of `Task#trigger_transition` after a successful status change. | + +## Transition and duplicate guards + +`task_submitted` shares the successful-transition seam used by EN-E02's +`task_status_changed`, but the two events have disjoint actor guards: + +- `task_submitted` only runs for a student or group member moving a task into + `TaskStatus.ready_for_feedback`; +- `task_status_changed` only runs for a tutor changing a student's task. + +The previous and current status IDs are compared, so submitting again while the +task is already ready for feedback does not send another email. + +Group submissions can invoke the transition once per member task and then +visit those tasks again through submission propagation. Calls marked +`group_transition: true` are suppressed, leaving the original action as the +single notification boundary. + +## Privacy + +The email names the student and task because the recipient is the assigned +tutor and needs both to identify the waiting work. It does not contain uploaded +work, marks, grades, feedback, comments, or any other assessment content. The +link opens the authenticated task page in OnTrack. + +## Volume + +Independent submissions still produce one immediate email each. A tutor with +many students may therefore receive many messages in a short period. A digest +would require queueing and aggregation work beyond this event; this +implementation prevents duplicate amplification but does not batch unrelated +submissions. + +## How to check it by hand + +1. Sign in as a student and submit a task for marking. +2. Confirm exactly one email arrives for the tutor returned by + `project.tutor_for(task_definition)`. +3. Confirm the email names the student and task, links to the task, and contains + no submission or assessment content. +4. Repeat with the tutor's task notifications disabled and confirm no + notification is created or delivered. + +## Tests + +`test/models/notification_task_submitted_test.rb` + +The focused tests cover the recipient and preference, exact event/category, +ready-for-marking status guard, EN-E02 separation, duplicate and group +propagation guards, HTML/text copy, task link, nil tutor, push payload shape, +and failure isolation. diff --git a/docs/notifications/events/tutorial_changed.md b/docs/notifications/events/tutorial_changed.md new file mode 100644 index 0000000000..c0647694f5 --- /dev/null +++ b/docs/notifications/events/tutorial_changed.md @@ -0,0 +1,55 @@ +# Event: tutorial_changed + +| Field | Value | +|---|---| +| Event name | `tutorial_changed` | +| Category | `general` | +| What triggers it | An existing tutorial enrolment is moved in place by `Project#enrol_in`. A first enrolment, selecting the same tutorial again, and the multiple-enrolment collapse path do not trigger it. | +| Who receives it | Only the affected project's student (`project.student`). Students in the old or new tutorial are not notified. | +| Preference that gates it | none, always sent | +| Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | +| Email body summary | Names the new tutorial and gives its meeting day and time. It deliberately omits the old tutorial and any other student's details. Templates are `app/views/notifications_mailer/tutorial_changed.text.erb` and `tutorial_changed.html.erb`. | +| Where it is raised | `app/models/project.rb`, in the existing-enrolment update branch of `Project#enrol_in`, through the private `notify_tutorial_changed` helper | + +## Recipient and trigger guards + +The notification is addressed directly to `project.student`. It is not fanned +out through either tutorial's enrolments, so one student's move creates one +notification and one email. + +`Project#enrol_in` has distinct paths for creating a first enrolment, collapsing +multiple stream enrolments into a single non-stream enrolment, and updating an +existing enrolment. Only the final path calls `notify_tutorial_changed`, after +the tutorial enrolment update succeeds. Selecting the current tutorial returns +before any of those paths and does not notify. + +## Notification fields + +- Type: `general` +- Event: `tutorial_changed` +- Recipient: `project.student` +- Message: names the new tutorial abbreviation, unit, meeting day and meeting time +- Link: `/projects/:project_id/dashboard` +- Preference: none; `general` has no entry in `Notification::PREFERENCE_FOR_TYPE` + +The previous tutorial is intentionally absent from the notification record, +email and push body. Marks, feedback, comments and other student names are not +included. + +Notification errors are logged and do not roll back a successful tutorial move. + +## How to check it by hand + +1. As a convenor, move one student from an existing tutorial to another. +2. Confirm that exactly one email is sent to that student. +3. Confirm that the email names the new tutorial and its day and time, and does + not name the old tutorial or any other student. +4. Add a student to their first tutorial and confirm that no email is sent. + +## Tests + +`test/models/notification_tutorial_test.rb` + +The tests cover the affected-student recipient, notification fields and push +link, both email formats, privacy-safe copy, first enrolment, the same-tutorial +no-op, the multiple-enrolment collapse path, and notification failure isolation. diff --git a/docs/notifications/existing-emails.md b/docs/notifications/existing-emails.md new file mode 100644 index 0000000000..f545ad6902 --- /dev/null +++ b/docs/notifications/existing-emails.md @@ -0,0 +1,196 @@ +# Existing Email Audit + +This document records email behaviour that already exists in OnTrack and the +shared email-delivery paths used by the v2 notification work. Its purpose is to +prevent new notification events from duplicating existing email behaviour or +accidentally sending multiple messages for the same action. + +The audit covers both the API mailers and the existing communication subsystem +in the web application. + +## Audit snapshot + +This audit was checked against `feature/notifications` at commit +`09a61714425f12e1412da5b7a34f31f7ea5612dd` on 15 August 2026. + +The primary reference for each send site is its class and method name. The +linked line ranges are pinned to the audited commit so later code changes do not +make the references point to unrelated code. + +The audit can be reproduced with: + +```bash +git grep -nE '\.deliver(_now|_later)?([^[:alnum:]_]|$)' \ + 09a61714425f12e1412da5b7a34f31f7ea5612dd -- app lib \ + | grep -Ev 'PushNotificationService\.deliver|def (self\.)?deliver(_now|_later)?([^[:alnum:]_]|$)' +``` + +The command returned 21 direct email delivery call sites after the push +delivery call was excluded. Each result was manually checked to confirm that +it sends an email. + +## API email send sites + +A search of `doubtfire-api` for `.deliver`, `.deliver_now`, and +`.deliver_later` identified 21 real email send sites. Push notification service +calls and method definitions are not counted as email send sites. + +| Existing email | Trigger / stable send-site reference | Recipient | Preference / guard | +| --- | --- | --- | --- | +| Turnitin error log | [`TurnItIn.handle_tii_error`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/helpers/turn_it_in.rb#L72-L80) – a Turnitin request returns HTTP 403 | Configured administrator/error-log address | Operational path; no user preference; attempted only for a 403 error | +| Task PDF failed – queued converter | [`PortfolioEvidence.process_new_to_pdf`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/portfolio_evidence.rb#L31-L75) – queued task PDF conversion reports a failure | Project student | `receive_task_notifications` | +| Weekly student summary | [`Project#send_weekly_status_email`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/project.rb#L619-L636) – weekly project summary is generated | Project student | `receive_feedback_notifications`; a final summary is skipped when a portfolio already exists | +| Task feedback ready | [`Unit#update_task_status_from_csv`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/unit.rb#L2632-L2755) – batch CSV/ZIP marking import finishes feedback/PDF processing | Project student | `receive_feedback_notifications` | +| Weekly staff summary | [`UnitRole#send_weekly_status_email`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/unit_role.rb#L187-L193) – weekly staff summary is generated | Staff member represented by the unit role | `receive_feedback_notifications` | +| Single notification email | [`NotificationService.notify` and `deliver_email`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/services/notification_service.rb#L24-L68) – an event is created and delivered through the notification service | Notification user | `task`, `feedback`, and `portfolio` map to existing preferences; `extension` and `general` currently have no mapping and are allowed by default | +| Task PDF failed – submission job | [`AcceptSubmissionJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/accept_submission_job.rb#L10-L55) – submitted task PDF conversion fails | Project student | `receive_task_notifications` | +| Submission processing error | [`AcceptSubmissionJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/accept_submission_job.rb#L10-L55) – submission processing raises an exception | Configured administrator/error recipient | Operational path; no user preference; only sent when an error mail is available | +| Archive error | [`ArchiveOldUnitsJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/archive_old_units_job.rb#L6-L24) – old-unit archiving raises an exception | Configured administrator/error recipient | Operational path; no user preference | +| D2L grade transfer result | [`D2lPostGradesJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/d2l_post_grades_job.rb#L9-L37) – D2L grade transfer completes | User who initiated the transfer | Direct workflow result; no notification preference check | +| D2L grade transfer failure | [`D2lPostGradesJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/d2l_post_grades_job.rb#L9-L37) – D2L grade transfer fails | User who initiated the transfer | Direct workflow result; no notification preference check | +| Communication email to student | [`ExecuteCommunicationSetJob#execute_email_student_action`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/execute_communication_set_job.rb#L116-L159) – an active communication rule matches a student and executes its student-email action | Student matched by the communication rule | No v2 preference check; requires rule match, configured action, recipient email and sender email | +| Communication email to staff | [`ExecuteCommunicationSetJob#execute_email_staff_action`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/execute_communication_set_job.rb#L163-L198) – a staff-email action executes | Tutors and/or convenors selected by the rule | No v2 preference check; requires configured recipient groups, available recipient addresses and sender email | +| Communication action log | [`ExecuteCommunicationSetJob#send_action_log_to_convenors`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/execute_communication_set_job.rb#L264-L311) – a communication execution produces its action log | Convenors | Requires `send_log_to_convenors?`, convenor addresses and sender email; no v2 preference check | +| Tutor note | [`NotifyTutorNotesJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/notify_tutor_notes_job.rb#L4-L10) – tutor-note notification job runs | Specific recipient supplied to the job | No preference check in this job path | +| PDF-generation error mail | [`submission:generate_pdfs`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/generate_pdfs.rake#L86-L149) – portfolio/PDF generation raises an exception | Configured administrator/error recipient | Operational path; no user preference | +| Portfolio ready | [`submission:generate_pdfs`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/generate_pdfs.rake#L86-L149) – portfolio generation succeeds | Project student | `receive_portfolio_notifications` | +| Portfolio failed | [`submission:generate_pdfs`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/generate_pdfs.rake#L86-L149) – portfolio generation fails | Project student | `receive_portfolio_notifications` | +| Task PDF failed – maintenance | [`notify_failed_submission`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/maintenance.rake#L42-L68) – maintenance PDF processing identifies a failed submission | Project student | `receive_task_notifications` | +| Maintenance error mail | [`notify_failed_submission`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/maintenance.rake#L42-L68) – maintenance processing raises an error while handling the failure | Configured administrator/error recipient | Operational path; no user preference | +| Overseer assessment failed | [`notify_failed_overseer_assessments!`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/overseer_notifications.rake#L2-L18) – unnotified Overseer assessment failures are grouped for delivery | Affected project student | Requires queued failure records and a nonblank student email; no explicit user preference check in this method | + +## Mailers already present + +The API currently contains the following relevant mailers: + +- `CommunicationsMailer` – sends configurable communication emails and + communication action logs. +- `D2lResultMailer` – reports D2L grade-transfer results. +- `ErrorLogMailer` – sends operational/error reports. +- `NotificationsMailer` – sends single event notifications and weekly student + and staff summaries. +- `PortfolioEvidenceMailer` – handles task PDF failure, task feedback ready, + Overseer assessment failure, portfolio ready and portfolio failed emails. +- `TutorNoteMailer` – sends tutor-note notifications to a supplied recipient. + +`ConvenorContactMailer#request_project_membership` and +`PortfolioEvidenceMailer#task_pdf_ready_message` also exist, but no active +`.deliver`/`.deliver_now` send site was found for either during this audit. +They are therefore not counted among the 21 current email send sites. + +## Existing web communication subsystem + +The web application already contains a unit communications editor under: + +`src/app/units/states/edit/directives/unit-communications-editor/` + +This is an existing communication system rather than a placeholder for future +notification work. + +A convenor can configure communication rules with conditions and actions. +Available actions include: + +- Send email to student +- Send email to staff +- Add a task comment +- Change target grade + +Student emails support a configurable subject and body. Staff emails also +support configurable subject/body content and can target tutors, convenors, or +both. + +Communication rules can filter students using existing conditions including +task status, target grade, login status, special consideration, tutorial, +tutorial stream and campus. + +The subsystem also supports scheduled communication sets. A schedule can run +once or recur daily, weekly or monthly. It supports a start week/day/time, +timezone, recurrence interval, repeat count and optional end date. + +When a communication set executes, matched students can receive the configured +actions. The execution logic also prevents a student matched by an earlier rule +in the same set from being processed again by a later rule. + +## Duplicate-email risks + +The existing communications subsystem is the largest duplication risk for v2. +OnTrack can already send configurable email to students and staff, including +scheduled and recurring communication across a unit. A new event should not +reimplement this behaviour without first deciding whether the event belongs in +the existing communication-rule system. + +Portfolio events are another clear overlap. OnTrack already emails a student +when portfolio generation succeeds and when it fails. A v2 portfolio event that +also sends email could therefore double-mail the same student. + +Task and feedback events must also be checked against +`PortfolioEvidenceMailer`, weekly summaries and the communication-rule system. +Existing task-PDF failures and feedback-ready messages already reach students. + +Tutor-note and task-comment work also require care. Tutor notes already have a +direct email path, while the communication editor can create task comments. +New notification hooks around these actions should establish whether the +existing email is being replaced, supplemented, or intentionally left alone. + +`NotificationService` introduces another duplication boundary. Events routed +through it can generate a notification email after the relevant notification +preference check. An event must not also retain an independent legacy email +unless two messages are explicitly intended. + +The current target branch also includes the `task_status_changed` event. This +event calls `NotificationService.notify`, so it reuses the single notification +email delivery path listed above. It does not introduce a separate direct +`.deliver`, `.deliver_now`, or `.deliver_later` call and therefore does not +increase the direct send-site count. + +## Recipient and preference observations + +Existing emails do not use one common preference mechanism. + +Task-PDF failure paths use `receive_task_notifications`. The batch feedback-ready +email, weekly student summary and weekly staff summary use +`receive_feedback_notifications`. Portfolio-ready and portfolio-failed emails +use `receive_portfolio_notifications`. + +`NotificationService` only maps `task`, `feedback`, and `portfolio` to existing +preference fields. The `extension` and `general` types have no preference +mapping, so `NotificationService.deliver_to?` currently allows them by default. + +Communication-rule emails do not use the v2 preference mapping. They are +controlled by rule matching, action configuration, available recipient +addresses and an available sender address. The action-log email also requires +the rule's `send_log_to_convenors?` setting and at least one convenor email. + +Administrator error emails and D2L result emails have no user notification +preference check. Error emails depend on the operational error-email +configuration, while D2L result emails are sent directly to the user who +initiated the transfer. + +This distinction must be preserved when an existing email is migrated or +connected to a v2 event. Adding a second preference check without understanding +the legacy path could suppress a required operational email. Keeping both an +independent legacy send and a v2 send could instead cause duplicate user-facing +email. + +## Conclusion + +OnTrack already has substantial email functionality in both repositories. + +In particular: + +1. Students are already emailed when their portfolio is ready or generation + fails. +2. Students already receive task, feedback, summary and Overseer-related + emails in existing flows. +3. Staff already receive weekly summaries and can be targeted through the unit + communications system. +4. Convenors can already configure and schedule email to a unit without any + new v2 notification feature. +5. The communication subsystem supports both student and staff email and + recurring schedules. +6. New v2 events must be checked against these send paths before another email + channel is added. + +The safest rule for subsequent event tickets is therefore: before adding an +email delivery path, check this audit and the existing communication subsystem +to determine whether OnTrack already sends an equivalent message. diff --git a/docs/notifications/push-opt-in-permission-flow.md b/docs/notifications/push-opt-in-permission-flow.md new file mode 100644 index 0000000000..d2c7399380 --- /dev/null +++ b/docs/notifications/push-opt-in-permission-flow.md @@ -0,0 +1,270 @@ +# Push opt-in and permission flow + +MN-D02. This document records the user-visible flow for enabling and disabling +Web Push on one device, the browser and server state behind it, and the recovery +path for each known failure. + +This is a documentation-only ticket. It does not change permission, subscription, +delivery, or sign-out behaviour. + +Push is device-specific. Turning it on stores this browser's subscription for +the signed-in user; it does not opt every browser or phone into push. Existing +notification category preferences still apply. For example, turning task +notifications off prevents task notifications on every channel, even when this +device remains subscribed to push. + +For server configuration, payload shape, and delivery diagnostics, read +[`push-setup.md`](./push-setup.md). For secure-context rules, service-worker +cleanup, and phone tunnel setup, read +[`testing-push-locally.md`](./testing-push-locally.md). + +## Happy path + +The browser permission prompt must follow a user action. OnTrack therefore asks +for permission only after the user clicks the push setting; it never prompts on +sign-in or page load. + +```text +Sign in + -> open Profile + -> wait for the service worker to start + -> click "Turn on push notifications on this device" + -> grant the browser's notification permission + -> browser creates a PushSubscription using the server's VAPID public key + -> web app POSTs endpoint, p256dh, and auth to /api/push_subscriptions + -> api stores or updates the subscription for the signed-in user + -> another user action raises a notification event + -> api sends the push through the stored endpoint + -> service worker displays the notification +``` + +In more detail: + +1. The user signs in and opens **Profile**. The push control is part of the + edit-profile form, below the notification category settings. +2. The Angular service worker registers about six seconds after application + bootstrap. Until it is ready, the button is disabled and the page says: + **Still starting up. This becomes available a few seconds after the page + loads.** +3. `PushNotificationService.blocker()` checks that the browser exposes the + Notifications and Push APIs, permission has not already been denied, VAPID + is configured, and `SwPush` is enabled. +4. The user clicks **Turn on push notifications on this device**. The button is + disabled while the request is running so a second click cannot create a + competing request. +5. `SwPush.requestSubscription` asks the browser for permission and supplies + the VAPID public key published by the api. +6. After permission is granted, the browser returns a subscription. The web app + posts its `endpoint`, `p256dh`, and `auth` values to the authenticated + `POST /api/push_subscriptions` endpoint. +7. The api validates the endpoint as a recognised HTTPS push service and stores + it for the current user. Posting the same endpoint again updates its keys + rather than creating a duplicate. If the endpoint previously belonged to + another account on the same browser, ownership moves to the current user. +8. The page shows **This device will receive push notifications**, the button + changes to **Turn off push notifications on this device**, and a toast says + **Push notifications turned on**. +9. When an enabled notification event is raised, + `NotificationService.notify` creates the in-app notification and calls the + push delivery service. The service sends the Angular notification payload to + every stored subscription for the recipient. `ngsw-worker.js` displays it. + +Permission and subscription are different state. Browser permission allows the +site to show notifications. The `PushSubscription` gives the api a destination +and encryption keys. A user needs both, plus an active service worker and an +enabled notification category, before an event can appear as a push. + +## What the user sees + +| State | Push control | User-visible result | Recovery | +| ------------------------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Service worker is starting | Disabled | **Still starting up. This becomes available a few seconds after the page loads.** | Wait at least six seconds. If it persists, follow the service-worker checks below. | +| Ready, permission is `default` | Enabled | **Turn on push notifications on this device** | Click the button and choose **Allow** in the browser prompt. | +| Subscribing | Disabled | The existing button remains visible while the request runs. | Wait for the success or error toast; do not reload during the prompt. | +| Subscribed | Enabled | **Turn off push notifications on this device**, plus **This device will receive push notifications.** | No action is needed. A real event is still required to prove delivery. | +| Permission denied | Disabled | **You have blocked notifications for this site**, followed by browser-specific steps for Chrome, Edge, or Firefox, or generic steps for another browser. | Allow notifications in site settings and reload. A site cannot override a denial or prompt again by itself. | +| Push API unsupported | Disabled | **This browser does not support push notifications.** | Use a browser and device that expose Web Push in a secure context, and check that platform's installation requirements. | +| VAPID not configured | Disabled | **Push notifications are not set up on this server.** | An operator must configure the api. The user cannot correct this in the browser. | +| Request fails after a denial | Disabled on the next state check | Toast: **Notifications are blocked in your browser**. | Change the site's notification permission to Allow, then reload. | +| Other subscribe or unsubscribe error | Depends on the browser subscription that remains | Toast: **Could not change push notifications**. | Check the api response and logs, then retry. Do not treat the toast as evidence that local and server state match. | + +The browser-specific denial instructions come from +`PERMISSION_DENIED_INSTRUCTIONS` in the web push service. Opera and unrecognised +browsers deliberately use the generic instructions rather than Chrome's steps. + +## Failure and recovery paths + +### Permission was denied + +`Notification.permission === 'denied'` is a hard blocker. OnTrack disables the +button because browsers do not let a site reverse that decision or show the +permission prompt again. The page explains how to reopen the site's permission +settings for Chrome, Edge, and Firefox. After changing the permission, reload so +the blocker and the local subscription are read again. + +If the user denies the first prompt, `requestSubscription` rejects. The immediate +feedback is the **Notifications are blocked in your browser** toast; after the +state refresh or reload, the disabled control and recovery steps explain what to +do next. + +### The browser or context is unsupported + +OnTrack reports **This browser does not support push notifications** when either +`Notification` or `PushManager` is absent. This can mean the browser genuinely +lacks Web Push, but it can also mean the page is outside a secure context. + +`http://localhost` is treated as trustworthy for local development. A phone +opened at a laptop's plain HTTP LAN address is not. Use HTTPS for a real device +and check: + +```js +window.isSecureContext && "serviceWorker" in navigator; +``` + +The result must be `true`. The phone and tunnel procedure is in +[`testing-push-locally.md`](./testing-push-locally.md). + +### The service worker is missing or stuck + +`SwPush.isEnabled` is false during the normal six-second registration delay and +when the current build did not generate or register `ngsw-worker.js`. Both cases +show the same temporary starting-up message. + +If the button stays disabled: + +1. Request `/ngsw-worker.js` and confirm it returns `200`, not `404`. +2. Confirm the browser shows an activated service worker for the current origin. +3. Clear stale registrations and caches using + `doubtfire-web/docs/service-worker.md` or the browser-specific steps in + [`testing-push-locally.md`](./testing-push-locally.md). +4. Reload and wait for registration before reopening the push setting. + +### The subscription expired or was invalidated + +When a push service answers `404` or `410`, the api treats the registration as +dead and deletes its `push_subscriptions` row. Temporary failures stay inside +the asynchronous delivery path: they are raised to Sidekiq for retry and never +propagate to the original request, in-app record, or email hand-off. + +There is currently no message back to the open browser when this cleanup +happens. Its local `SwPush.subscription` can therefore still make Profile say +**This device will receive push notifications** even though the api no longer +has a destination. The symptom is a subscribed-looking device that receives no +push and has no row on the api. + +To recover today, click **Turn off push notifications on this device** and then +turn it on again. The web app still removes the local subscription when the api +delete returns `404`, so a fresh opt-in can create and store a new endpoint. +Automatic re-registration when a browser rotates its subscription belongs to +MN-C05; this flow does not claim it already happens. + +### The operating system muted the browser + +Browser permission can be `granted` while the operating system blocks the +browser's notifications, Focus or Do Not Disturb suppresses them, or the alert +style shows no banner. The web app cannot see those operating-system settings. +It continues to show **This device will receive push notifications**, and the +api can successfully send, while nothing appears on screen. + +Use a local notification to separate an operating-system problem from an +OnTrack delivery problem: + +```js +const registration = await navigator.serviceWorker.ready; +await registration.showNotification("OnTrack", { + body: "Local notification test; no api or push service involved", +}); +``` + +If this does not appear, allow notifications for the browser in the operating +system, choose a visible alert style, and turn off Focus or Do Not Disturb. If it +does appear, continue with the subscription, recipient, VAPID, and api-log checks +in [`push-setup.md`](./push-setup.md). + +### A delivery service or api call failed + +Temporary push errors are logged and the server keeps the subscription. One +failing browser never blocks delivery to the recipient's other devices and +never blocks the in-app notification or email. + +A failed opt-in API request produces **Could not change push notifications**. +Because the browser may already have created its local subscription before the +POST fails, verify both sides rather than relying on the toast: + +```js +await navigator.serviceWorker.ready.then((registration) => + registration.pushManager.getSubscription(), +); +``` + +```sh +docker exec doubtfire-api bundle exec rails runner \ + 'puts PushSubscription.all.map { |s| "#{s.user.username} #{s.endpoint[0, 60]}" }' +``` + +If the local subscription exists but the api row does not, turn push off and on +after correcting the api error. + +## Revoking push on this device + +Clicking **Turn off push notifications on this device** performs two operations +in this order: + +1. `DELETE /api/push_subscriptions?endpoint=...` removes the signed-in user's + server row while the auth token and endpoint still exist. +2. `SwPush.unsubscribe()` removes the browser subscription. + +Server deletion goes first because local unsubscribe discards the endpoint the +api needs to find the row. If the delete fails, the web app still unsubscribes +locally. The leftover server row is harmless and is removed when a later send +receives `404` or `410`. On success, the toast says **Push notifications turned +off** and the control returns to its opt-in state. + +Revoking the site's permission directly in browser settings is different from +using the OnTrack button. The page can detect that permission is now denied, but +the current code does not proactively delete the api row in response to a +permission change. Browser cleanup varies; any dead row is removed after the +push service reports it as expired or invalid. Use the OnTrack control when +possible so both sides are cleaned up deliberately. + +## Sign-out ordering + +Sign-out also removes push because a browser subscription survives an ordinary +web session. Leaving it behind on a shared device could send the previous +user's notifications after another person signs in. + +`AuthenticationService.signOut` therefore: + +1. calls `PushNotificationService.unsubscribeQuietly()` while the current + user's auth token still exists; +2. deletes the api row before unsubscribing in the browser; +3. continues deleting the server session and local auth token whether push + cleanup succeeds or fails; and +4. completes sign-out even when the service worker is disabled or the browser + refuses to unsubscribe. + +The quiet variant is deliberate: push cleanup protects a shared device, but an +outage must never trap someone in a signed-in session. When there is no active +service worker, sign-out cannot read a local endpoint and returns immediately; +any server row it could not address is left for delivery-time cleanup. + +## Source map + +| Responsibility | Source | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Push control, status text, busy state, and toasts | `doubtfire-web/src/app/common/edit-profile-form/edit-profile-form.component.ts` and `.html` | +| Blocker order, browser guidance, subscribe, and unsubscribe | `doubtfire-web/src/app/api/services/push-notification.service.ts` | +| Service-worker registration delay | `doubtfire-web/src/app/doubtfire-angular.module.ts` | +| Sign-out cleanup ordering | `doubtfire-web/src/app/api/services/authentication.service.ts` | +| Authenticated store, update, ownership move, and delete endpoints | `doubtfire-api/app/api/push_subscriptions_api.rb` | +| Endpoint validation and uniqueness | `doubtfire-api/app/models/push_subscription.rb` | +| Push fan-out, payload, expired-row cleanup, and error isolation | `doubtfire-api/app/services/push_notification_service.rb` | + +## Verification boundary + +The service and API tests cover the state decisions and request ordering, but +they cannot prove that a browser or operating system displayed a notification. +A complete manual verification must record the browser and OS versions, grant +permission through the Profile control, confirm the api row, trigger a real +event for the subscribed user, observe the notification, and test its click +destination. Use the local testing guide for that end-to-end procedure. diff --git a/docs/notifications/push-setup.md b/docs/notifications/push-setup.md new file mode 100644 index 0000000000..d8f1dd9b10 --- /dev/null +++ b/docs/notifications/push-setup.md @@ -0,0 +1,169 @@ +# Web push setup + +How the push channel works, how to turn it on, and how to check it is working. + +## What push needs + +Three things, and push is a no-op until all three are true: + +1. **VAPID keys on the api.** Without them `PushNotificationService.configured?` + is false and `deliver` returns immediately. The app behaves exactly as it did + before push existed. +2. **A row in `push_subscriptions`.** A browser has to register itself first. + MN-F01 added the table and the API. +3. **A service worker in the browser.** MN-F03 turns it on for development. + Without it, the browser has nothing to receive a push with. Setup, + caching side effects and how to clear a stuck worker are in the web repo: + `doubtfire-web/docs/service-worker.md`. + +Miss any one and nothing arrives, with no error anywhere. Check them in order. + +## The keys + +VAPID is how a push service knows the push came from us and not from anyone else +who happens to know a browser's endpoint URL. It is one key pair for the whole +server, not one per user. + +Generate a pair: + + docker exec doubtfire-api bundle exec ruby -e \ + "require 'web_push'; k = WebPush.generate_key; puts k.public_key; puts k.private_key" + +Then set three environment variables on the api: + +| Variable | What it is | +|---|---| +| `DOUBTFIRE_VAPID_PUBLIC_KEY` | Public half. The browser needs this to subscribe. | +| `DOUBTFIRE_VAPID_PRIVATE_KEY` | **Secret.** Signs every push. Never commit a real one. | +| `DOUBTFIRE_VAPID_SUBJECT` | A `mailto:` or URL the push service can contact. Optional; falls back to the institution host. | + +`development/docker-compose.yml` in the deploy repo already carries a throwaway +pair so the local stack works out of the box, on the same footing as +`DF_SECRET_KEY_BASE`. That pair is development only. **A production deployment +sets its own through real secrets, and if the private key ever leaks, generate a +new pair — every existing subscription becomes useless and users have to +re-subscribe.** + +Changing the keys does not migrate anything. The `push_subscriptions` rows stay, +but pushes signed with the new key are rejected for browsers that subscribed +under the old one. The delivery service deliberately retains generic failures, +including 403 responses, because they can also be transient configuration +errors. When rotating a VAPID pair, explicitly delete all existing +`push_subscriptions` rows and tell users to enable push again. + +## How a notification becomes a push + +`NotificationService.notify` queues `PushNotificationDeliveryJob` with only the +notification id. A Sidekiq worker reloads the notification and calls +`PushNotificationService.deliver`, so **every event that queues an email also +queues a push, with no per-event work**. Provider network I/O never blocks the +request or runs under the notification hand-off lock. Push jobs use the +dedicated `notifications` queue; every environment that enables Web Push must +run a worker for that queue. + +`deliver` loops over `notification.user.push_subscriptions` and sends this +payload to each: + +```json +{ + "notification": { + "title": "OnTrack", + "body": "Andrew Cain commented on 1.1P in COS10001.", + "data": { "notification_id": 12, "link": "/projects/2/dashboard/1.1P" } + } +} +``` + +The top level `notification` key matters. Angular's own `ngsw-worker.js` looks +for exactly that and displays the notification itself. **Change the shape and +somebody has to write a service worker by hand.** + +`data.link` is what MN-C03 reads to decide where to send the user on click. +Delivery uses normal urgency and a one-hour TTL so a short disconnect can +recover without a push service surfacing workflow alerts days or weeks late. + +## Failure handling + +- **404 or 410** means the browser threw the registration away. The row is + deleted, because nothing will ever reach it again. +- **Anything else** (429 rate limit, the push service being down, a rejected + payload) is logged and retained. Delivery continues to the user's remaining + browsers, then the aggregate failure is raised so Sidekiq can retry. The + subscription is kept because deleting on a temporary outage would silently + unsubscribe people the first time a push service had a bad day. +- The delivery job is configured for three Sidekiq retries. A missing + notification row is also retryable because a fast worker may run before an + enclosing producer transaction commits. +- Nothing propagates to the request caller. A push failure must never block the + in-app notification or the email hand-off. + +## Checking it works + +**Are the keys loaded?** + + docker exec doubtfire-api bundle exec rails runner \ + 'puts PushNotificationService.configured?' + +`false` means the api container was started before the variables were added. +`restart` does not pick up new environment variables. Recreate it: + + docker compose -f docker-compose.yml -f docker-compose.local-paths.yml up -d doubtfire-api + +**Is a browser registered?** + + docker exec doubtfire-api bundle exec rails runner \ + 'puts PushSubscription.all.map { |s| "#{s.user.username} #{s.endpoint[0, 60]}" }' + +Empty means nothing has subscribed yet. That is the usual reason a push does not +arrive, and it looks identical to push being broken. + +**Subscribe this browser by hand for diagnostics.** The normal path is the +in-app opt-in control. To isolate that UI from the API and service worker, paste +this into the dev tools console on a page where you are signed in. + +```js +const VAPID = '' +const b64 = s => Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)) + +const reg = await navigator.serviceWorker.ready +const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: b64(VAPID) +}) +const j = sub.toJSON() + +await fetch('/api/push_subscriptions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Username': localStorage.getItem('username'), + 'Auth-Token': localStorage.getItem('authToken') + }, + body: JSON.stringify({ endpoint: j.endpoint, p256dh: j.keys.p256dh, auth: j.keys.auth }) +}) +``` + +Then raise a notification (post a task comment) and a desktop notification +should appear. + +**Nothing appeared?** Check in this order, because each step is invisible when it +fails: + +1. Browser notification permission. `Notification.permission` must be `granted`. + macOS also has to allow notifications from the browser, in System Settings. +2. `PushNotificationService.configured?` is true. +3. A `push_subscriptions` row exists for the user the notification went to. It is + easy to subscribe as one account and then trigger a notification for another. +4. The Sidekiq worker consumes the `notifications` queue. The normal development + stack starts it with `-q mailers -q notifications`; it deliberately does not + consume the unrelated `default` queue. +5. `docker logs doubtfire-sidekiq | grep -i "push"`. Delivery failures happen in + the worker, so this is the process that reports them. + +## Why the gem is pinned + +`Gemfile` pins `web-push` to exactly `3.0.1`. This release still depends on +`jwt ~> 2.0`, so it remains compatible with the authentication dependencies in +this release, while replacing the separate `hkdf` gem with `OpenSSL::KDF`. +`web-push` 3.0.2 is the release that moves to JWT 3; upgrading beyond 3.0.1 +therefore needs authentication and OAuth regression testing. diff --git a/docs/notifications/pwa-offline.md b/docs/notifications/pwa-offline.md new file mode 100644 index 0000000000..bcaf16252f --- /dev/null +++ b/docs/notifications/pwa-offline.md @@ -0,0 +1,264 @@ +# PWA offline behaviour + +This document records what users experience when OnTrack loses network +connectivity and explains the relevant Angular service-worker configuration. + +No caching configuration or application behaviour was changed as part of this +documentation-only investigation. + +## Configuration under test + +Testing used the generated static development build from the +`doubtfire-web/feature/notifications` branch. + +Before the final offline tests, the environment confirmed that: + +- `navigator.serviceWorker.controller` referenced `ngsw-worker.js`. +- `/ngsw/state` reported `Driver state: NORMAL ((nominal))`. +- `/index.html` existed in the versioned `app` asset cache. +- **Bypass for network** was disabled. +- **Update on reload** was disabled. +- Ordinary reloads were used rather than forced or hard refreshes. + +The generated static build was used for the final test so the service worker +could install and cache the built application files consistently. + +## What is cached + +### Application shell + +The `app` asset group uses `installMode: "prefetch"` and includes: + +- `/index.html` +- the compiled JavaScript bundles +- the compiled CSS bundles +- the favicon +- the web application manifest + +These resources are downloaded when the service-worker application version is +installed. + +The `assets` asset group uses `installMode: "lazy"`. Matching images, fonts, +and other assets are cached after they are requested rather than all being +downloaded during installation. + +### Navigation URLs + +The service-worker configuration contains these navigation rules: + +```json +[ + "/**", + "!/**/*.*", + "!/**/*__*", + "!/**/*__*/**", + "!/JPlag/**", + "!/JPlag", + "!/sidekiq/**", + "!/sidekiq", + "!/beta/**", + "!/beta", + "!/legacy", + "!/legacy/**" +] +``` + +A URL must match a positive rule and must not match any negative rule to be +treated as an Angular navigation request. + +The `!/**/*.*` rule is intended to exclude file URLs whose final path segment +contains a file extension. However, it also excludes valid OnTrack task routes +when the task abbreviation contains a period. + +For example: + +- `/` is treated as a navigation request. +- `/projects/2/dashboard` is treated as a navigation request. +- `/projects/28/dashboard/A15` is treated as a navigation request. +- `/projects/2/dashboard/2.2P` is not treated as a navigation request because + its final path segment contains a period. + +No `navigationRequestStrategy` override is configured. Angular therefore uses +its default `performance` navigation strategy for matching navigation +requests. This strategy serves the configured `/index.html`, which is normally +available from the application cache. + +An excluded URL is not redirected to the cached index file. When the network +is unavailable, the excluded request cannot be completed and may produce the +browser's native error page. + +### API data + +The `api` data group uses the following policy: + +```json +{ + "name": "api", + "urls": ["/api"], + "cacheConfig": { + "maxSize": 0, + "maxAge": "0u", + "strategy": "freshness" + } +} +``` + +`freshness` is a network-first strategy. The zero maximum size and zero maximum +age indicate that matching responses are not intended to provide a reusable +offline API cache. + +Users should therefore not rely on grades, task state, submission details, +comments, prerequisites, or other API-backed information being available +after connectivity is lost. + +This configuration should not be described as proof that an API response can +never be written to or returned from a service-worker cache under any +condition. Its practical effect and intent are that API data should not be +relied upon for meaningful offline reuse. + +A successful response status such as `200` or `304` does not, by itself, +identify where the response came from. The Network panel's **Size**, +**Transferred**, and **Initiator** information must be inspected before +identifying a response as coming from the service worker, memory cache, disk +cache, or network. + +## Observed offline behaviour + +### Included navigation route + +An ordinary offline reload of `/` loaded the cached Angular application shell +rather than Chrome's native `HTTP ERROR 504` page. + +Static resources such as the favicon and application icon were returned by the +service worker. Authentication, API, analytics, and other uncached requests +failed while the browser was offline. + +The application shell could therefore load, but API-backed application state +was unavailable or incomplete. This confirms that caching the shell does not +provide a complete offline mode. + +### Dotted task route + +An ordinary offline reload of: + +```text +/projects/2/dashboard/2.2P +``` + +produced Chrome's native `HTTP ERROR 504` page. + +Before this test: + +- `ngsw-worker.js` controlled the page. +- The service-worker driver state was normal. +- `/index.html` existed in the application cache. +- The reload was an ordinary reload rather than a forced refresh. + +The route was excluded from Angular navigation handling because its final +segment, `2.2P`, contains a period and matches the `!/**/*.*` negative rule. + +The service worker therefore did not use cached `/index.html` as the +navigation fallback. With the network unavailable, the route request failed +and Chrome displayed its native error page. + +This is a route-specific navigation limitation. It is not evidence that the +application shell was missing from the service-worker cache. + +### Losing connectivity mid-session + +When connectivity was removed without reloading, the already-loaded +application shell and information held in memory remained visible. + +Requests for new API-backed information failed. This included secondary task +requests such as prerequisite, submission-detail, and comment requests when +the information had not already been loaded. + +One observed failure produced this toast: + +> Failed to fetch prerequisites for task definition: TypeError: Cannot read +> properties of null (reading 'error') + +This is a technical error rather than a clear explanation that the application +has lost network connectivity. + +Some previously requested resources may still return successful statuses while +offline. Their source must be confirmed using the Network panel rather than +being inferred from the status code alone. + +## Summary + +OnTrack caches its Angular application shell, but offline reload behaviour is +route-dependent. + +Routes that satisfy the configured navigation rules can receive cached +`/index.html`. Valid task routes whose final path segment contains a period are +excluded by `!/**/*.*` and can produce a browser-level `504` when reloaded +offline. + +Even when the shell loads, API-backed information cannot be relied upon +offline. Losing connectivity can therefore leave the application shell visible +while new data requests fail and technical errors are displayed. + +The current behaviour can result in three different user experiences: + +1. An included navigation route loads the cached application shell, but + API-backed information is missing or fails. +2. A dotted task route produces Chrome's native `504` page because it is + excluded from navigation fallback. +3. Losing connectivity during an existing session leaves the shell visible + but can produce failed requests and technical error messages. + +## Recommendation + +A separate `doubtfire-web` ticket should investigate narrowing or replacing +the `!/**/*.*` navigation exclusion so valid task abbreviations containing +periods can use the Angular navigation fallback without treating genuine +static-file requests as application routes. + +A separate ticket should also consider: + +- displaying an offline or disconnected status; +- replacing technical request errors with user-friendly messages; +- safely handling absent network responses; +- providing a retry path after connectivity returns; and +- identifying which information, if any, should be available offline. + +These changes are outside the scope of this documentation-only ticket. + +## How to verify manually + +1. Build and serve the generated Angular output. +2. Load OnTrack while online. +3. Wait until the service worker is ready. +4. Confirm that + `navigator.serviceWorker.controller?.scriptURL` + ends in `ngsw-worker.js`. +5. Confirm that `/ngsw/state` reports + `Driver state: NORMAL ((nominal))`. +6. Confirm that `/index.html` exists in the versioned `app` asset cache. +7. Ensure **Bypass for network** is disabled. +8. Ensure **Update on reload** is disabled. +9. Load `/` while online. +10. Set Network throttling to **Offline**. +11. Use ordinary Command+R and confirm that the cached Angular shell loads. +12. Do not use Shift+Command+R or **Empty cache and hard reload**. +13. Return Network throttling to **No throttling**. +14. Load `/projects/2/dashboard/2.2P`. +15. Set Network throttling back to **Offline**. +16. Use ordinary Command+R. +17. Confirm that the dotted task route produces Chrome's native `504` page. +18. Return online and load a task. +19. Remove connectivity without reloading. +20. Navigate to information that has not already been requested. +21. Record the failed API requests and any user-facing error. +22. Use the Network panel's **Size**, **Transferred**, and **Initiator** + information before attributing successful responses to a particular cache. + +## Relevant configuration + +The behaviour described here is controlled primarily by: + +- `doubtfire-web/ngsw-config.json` +- the generated `ngsw.json` service-worker manifest +- Angular's service-worker navigation request handling +- the application's handling of failed API requests diff --git a/docs/notifications/reviews/android-phone-push-verification.md b/docs/notifications/reviews/android-phone-push-verification.md new file mode 100644 index 0000000000..3296c2226c --- /dev/null +++ b/docs/notifications/reviews/android-phone-push-verification.md @@ -0,0 +1,327 @@ +# MN-Q02 – Android phone push verification + +## Physical-device rerun — 28 August 2026 + +**THE OPERATING-SYSTEM DELIVERY AND TAP GATE PASSED.** A real Android phone +registered a fresh Web Push subscription, received an OnTrack notification, and +opened the installed OnTrack app when the notification was tapped. The recipient +then confirmed that the task feedback was present at the intended `1.1P` +destination. A final cold-launch rerun also crossed the sign-in screen and +resumed at the exact `1.1P` Feedback pane after authentication. This supersedes +the blocked 23 August attempt for the MN-MVP01 and ON-MVP01 physical-delivery +gate. + +The run also exposed phone usability defects. The fixed 400 px task list left +the task and feedback panes off screen; the Task Planner button overlapped the +floating grade label; and the attachment target in the feedback composer was +partly outside the viewport. The navigation and sign-in fixes are merged in +`doubtfire-web` PRs 123 and 124. The 48 px composer controls are live and covered +by PR 126. These defects did not invalidate the OS delivery result, but resolving +them was necessary for the notification destination to be usable on the phone. + +### Observed acceptance status + +| Ticket check | Result | Evidence | +| --------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Notification received on a real Android phone | **Met** | Recipient confirmed the OnTrack operating-system notification arrived after installing and launching the PWA. | +| Nothing private visible in notification copy | **Met for observed copy** | Visible body was `Andrew Cain commented on 1.1P in COS10001.`; the comment text, mark, grade, and tokens were absent. | +| Click navigates correctly | **Met** | Tap cold-launched OnTrack, crossed sign-in, and resumed at `COS10001` `1.1P` Feedback; recipient confirmed the new feedback was present. | +| Installed-app delivery path | **Met** | Delivery succeeded after installing from Chrome, launching the installed app, and cycling notification settings. | +| Lock-screen collapsed/expanded recording | **Awaiting replacement upload** | The recipient is replacing the recording in the existing evidence location; final duration and SHA may change. | +| Device/browser inventory | **Not recorded** | Manufacturer, Android version, and Chrome version still need to be transcribed from the device or recording. | + +The final video checksum and device inventory are evidence-administration items; +they do not reverse the directly observed OS delivery and tap result. They must +be added before claiming that every MN-Q02 archival-evidence checkbox is closed. + +### Exact run evidence + +| Item | Observed value | +| ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| Test date and timezone | 28 August 2026, Australia/Melbourne | +| API release head at final delivery | `8cdccc7944f64b75f5f85952f02671dd96cf3f98` | +| API head after session follow-up | `51d662850db15dabc710cf10972415553d03b761` | +| Web release head at final delivery | `fa3f50a6901c8ef82a0872d597757030d1bfb9fb` | +| Web head after usability follow-up | `024e12ee15e7c0309d36a621aff29b98bb4d8f6e` | +| Deploy head | `e791b57ba3e949e01285270f4bc0ea29fb23bb39` | +| HTTPS origin | Temporary `trycloudflare.com` tunnel; app, API, and service worker returned 200 | +| Synthetic actor | `acain` / Andrew Cain | +| Synthetic recipient | `student_1` | +| Event | Tutor text comment on project 2, task definition 1 (`COS10001` `1.1P`) | +| Accepted task comment | Comment 12, `Android auth-return proof — 2026-08-28 21:02:37 +1000` | +| Notification record | Notification 11 | +| Subscription | Row 2, endpoint host `fcm.googleapis.com`, refreshed at 19:42:36 AEST | +| Provider result | HTTP 201 `Created`; Sidekiq push job completed without exception at approximately 21:02:53 AEST | +| Visible title | `OnTrack` | +| Visible body | `Andrew Cain commented on 1.1P in COS10001.` | +| Intended route | `/projects/2/dashboard/1.1P/feedback` | +| Tap result | Installed OnTrack app opened sign-in when required, then resumed at the exact Feedback pane | + +The earlier accepted OS-delivery event was comment 5 / notification 5 at +09:30 AEST. Later diagnostic deliveries also returned HTTP 201 and were used to +isolate Android presentation, routing, and authentication state. The successful +device setup was: install OnTrack to the home screen, launch the installed app, +allow Android/browser notifications, and cycle the OnTrack notification controls +off and on once. + +### Follow-up phone-layout and authentication verification + +The primary navigation and feedback defects found by this rerun are merged in +`doubtfire-web` PR 123, `fix(dashboard): complete mobile navigation and feedback +routing`, at head `b9bc5abb9125d50251571ecf3f743c68398fd858` and merge commit +`3b7be9ffca5d563b25766bf4cf7487beb90897d7`. + +- unread comment deep links open a full-width Feedback pane; +- Tasks, Details, and Feedback are explicit phone controls; +- the comment viewer and composer fit the phone viewport and keyboard; +- the narrow header no longer clips the profile control; and +- desktop split-pane behaviour is unchanged. + +Live checks found no horizontal overflow at 360 or 430 px, and the targeted +dashboard/header suites, lint, typecheck, build, and GitHub CI passed. + +PR 124, `fix(mobile): restore notification return and dashboard spacing`, is +merged at head `f3077f05e72ae6133ddefc447594549ba22cfebf` and merge commit +`0ba9fd703155190e1d64a804157a6f2f5bdf2170`. + +- a protected destination survives refresh failure and sign-in in tab-scoped, + expiring storage; +- successful password login resumed at `/projects/2/dashboard/1.1P/feedback`; +- the same one-shot handoff can cross a future same-tab SSO redirect; +- external, malformed, stale, and authentication-loop destinations are rejected; + and +- at 390 px the planner button and grade field have 16 px separation, with the + floating label beginning 9.25 px below the button. + +The last physical-phone finding is covered by PR 126, +`fix(mobile): enlarge feedback composer actions`, at head +`f7cdb7b204ad03ba09b05530c022dd0b223faa52`. The same patch is running on the +live evidence origin as web head `024e12ee15e7c0309d36a621aff29b98bb4d8f6e`. + +- attachment and microphone targets are each 48 by 48 px; +- they begin at x=8 and x=60 instead of x=-7.2 and x=16.8; +- both target centres hit the intended enabled button; +- the feedback input retains 270 px width; and +- the 390 px composer has no horizontal overflow. + +The server-side refresh boundary found during the same cold-launch work is +covered by `doubtfire-api` PR 101, `fix(auth): renew refresh tokens before +expiry`, at head `51d662850db15dabc710cf10972415553d03b761`. The live evidence API +was restarted at that exact commit and returned HTTP 200 locally and through the +public origin. Expired and near-expiry refresh tokens now rotate, while tokens +outside the 12-hour renewal window are reused. + +## Previous result — 23 August 2026 + +**BLOCKED — NOT PASSED.** No physical Android phone was attached or otherwise +available for this verification on 23 August 2026 (Australia/Melbourne). The +required real-device delivery, lock-screen privacy, notification tap, and photo +evidence therefore do not exist. + +A `Pixel_8_Pro` Android Virtual Device is installed on the test host. It was not +used as a substitute: this ticket explicitly requires a real Android phone and a +photo of its lock screen. The host also had no `cloudflared`, `ngrok`, or +`tailscale` client available to expose the local app through the HTTPS origin +required by a phone. + +This report records the blocked attempt and the exact rerun needed to produce +valid evidence. It is not a sign-off of Android push support. + +## Acceptance status + +| Ticket check | Result | Evidence | +| --------------------------------------------- | ---------------- | ------------------------------------------------------------------------ | +| Work started | Met | Verification branch and this test record exist. | +| Notification received on a real Android phone | **Not met** | No physical Android phone was available. | +| Nothing private visible | **Not verified** | No notification reached a real lock screen. | +| Click navigates correctly | **Not verified** | There was no real-device notification to tap. | +| Photo attached | **Not met** | No real-device photo was produced. | +| Lock screen photographed | **Not met** | No physical lock screen was available. | +| Wording matches MN-D01 | **Not verified** | There is no observed title or body to compare with the approved wording. | + +The administrative “started” check is the only completed item. The ticket must +remain open until every real-device check above is evidenced. + +## Code and environment under review + +| Item | Value | +| ---------------------------- | ------------------------------------------------------ | +| API base | `doubtfire-api` `feature/notifications` at `564ff793` | +| Web base | `doubtfire-web` `feature/notifications` at `60b9ab7af` | +| Physical Android device | **Unavailable** | +| Android version | Not recorded — no device | +| Chrome version on device | Not recorded — no device | +| Installed virtual device | `Pixel_8_Pro` (not acceptable as real-phone evidence) | +| Public HTTPS tunnel | **Unavailable** | +| Photo or screenshot evidence | **None** | + +This branch adds only the test record. It does not change the push runtime. + +## Local service preflight + +The shared local QA setup was checked before the device step: + +| Check | Observed result | +| -------------------------------------------------------- | --------------- | +| Web app at `http://localhost:4200/` | HTTP 200 | +| Service worker at `http://localhost:4200/ngsw-worker.js` | HTTP 200 | +| API at `http://localhost:3000/api/settings` | HTTP 200 | +| `PushNotificationService.configured?` | `true` | +| `push_subscriptions` table present | `true` | +| Subscription rows | `0` | + +These results show that the local services and development VAPID configuration +were present. They do not prove Android delivery. A phone cannot use the +laptop's `localhost`, and a plain HTTP LAN address is not a secure context for a +service worker or Push API. With no HTTPS tunnel and no physical device, no phone +could opt in and the empty subscription table was expected. + +## Why execution stopped + +The following blockers are independent of the application code: + +1. No physical Android phone was available, so the defining acceptance condition + could not be exercised. +2. No HTTPS tunnel client was available. Loading the app over a laptop's plain + Wi-Fi address would not expose the service worker or Push API and would be an + invalid test. +3. With no phone and no secure public origin, no Android subscription could be + registered with the API. +4. Without a registered phone endpoint, there could be no server delivery, + lock-screen observation, tap-through result, or real-device photo. + +An emulator could help diagnose application behaviour later, but it cannot close +this ticket or supply the requested physical lock-screen evidence. + +## Privacy scenarios that must be observed + +The rerun must use synthetic course, user, and task data. For each observation, +compare the exact visible title and body with the approved MN-D01 wording rather +than inferring safety from the event name. + +| Scenario | Expected result | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Phone locked and face up; Chrome or the installed PWA is in the background | A useful OnTrack notification is visible without unlocking the phone. | +| A bystander reads the collapsed notification | No comment text, feedback, mark, grade, authentication data, or other private detail is visible. | +| Notification is expanded on the lock screen | Expansion reveals no additional private text. | +| Notification metadata is inspected | The endpoint, authentication keys, tokens, and internal payload identifiers are not displayed. | +| Test evidence is photographed | Only synthetic data is shown; unrelated notifications, device identifiers, and personal account details are excluded or redacted. | +| Notification is tapped after unlock | OnTrack opens the intended safe route for the test event and does not expose another user's data. | + +If an observed title or body differs from MN-D01, record the exact text and treat +that as a failure even if it happens to look harmless. The current push builder +uses the notification message as its body, so delivery alone is not proof that +the approved lock-screen copy was used. + +## Physical-device rerun procedure + +### 1. Record the test target + +Use an actual Android phone. Before starting, record: + +- manufacturer and model; +- Android version and security patch level; +- Chrome version; +- whether testing the browser tab or installed PWA; and +- test date, time, and network. + +Use the merge heads intended for release in both repositories and replace the +base commit values in this report if they have changed. + +### 2. Prove the local stack first + +Follow `docs/notifications/push-setup.md` and +`docs/notifications/testing-push-locally.md`. + +1. Confirm the API has development VAPID keys and + `PushNotificationService.configured?` returns `true`. +2. Confirm the web app, API, and `ngsw-worker.js` respond locally. +3. Receive a push in a supported desktop browser before adding the phone. This + isolates server and event problems from the mobile network path. +4. Record subscription rows before the phone subscribes. Do not copy complete + endpoint URLs or keys into the evidence. + +### 3. Create a secure phone origin + +1. Start one HTTPS tunnel to the web app on port 4200, following the documented + tunnel procedure. +2. Add only the generated tunnel hostname to the Angular dev server's allowed + hosts and to Rails development hosts, then recreate or restart the affected + services as documented. +3. Open the HTTPS URL on the phone and verify that the app and API load. +4. Confirm on the phone that `window.isSecureContext` is `true` and that + `serviceWorker` exists in `navigator`. + +Do not continue from a plain `http://192.168â€Ļ` LAN URL. It can render the app but +cannot produce a valid Push API test. + +### 4. Install, grant permission, and subscribe + +1. In Chrome on the phone, install OnTrack to the home screen and launch the + installed app. +2. Use a seeded, synthetic student account and wait for the service worker to + register. +3. Check Android **Settings → Apps → Chrome → Notifications** and the site's + notification permission. Both must allow notifications. +4. Temporarily configure the lock screen to show notification content, and turn + off Focus or Do Not Disturb for the observation. +5. Use OnTrack's push opt-in control and accept the browser permission prompt. +6. Confirm that exactly one new or updated API subscription row belongs to the + test user. Record only the row ID and endpoint host, not the full endpoint or + cryptographic keys. + +### 5. Deliver a real event while locked + +1. Choose a v1 event covered by MN-D01 and record its approved title and body in + the test notes. +2. Put the installed PWA in the background and lock the phone. +3. From a separate synthetic actor account, trigger the event through the normal + OnTrack workflow. Do not send a hand-built push directly to the endpoint. +4. Record the trigger time, event type, recipient, and sanitized notification ID. +5. Wait for the notification and record its arrival time and latency. + +### 6. Inspect and photograph the lock screen + +1. Photograph the physical phone showing the collapsed notification on the lock + screen. +2. Expand the notification and check that no additional private text appears. +3. Transcribe the exact visible title and body, including any truncation. +4. Compare both lines character-for-character with MN-D01. +5. Before attaching the photo, remove or redact unrelated notifications and + personal or device-identifying details. Do not redact the OnTrack wording that + is being reviewed. + +### 7. Verify the tap route + +1. Tap the notification and unlock the phone if prompted. +2. Record the final OnTrack route and page heading. +3. Confirm it is the intended destination for the event, is inside OnTrack, and + shows only data available to the recipient. +4. Capture a sanitized screenshot of the destination after navigation. + +If delivery, wording, privacy, or routing fails, preserve the sanitized evidence +and raise a separate defect with the phone/browser versions and reproducible +steps. Do not mark MN-Q02 passed merely because a subscription row was created. + +### 8. Clean up + +Revoke the test subscription, verify its API row is removed or invalidated, stop +the public tunnel, restore temporary host allowlists, and restore the phone's +lock-screen privacy settings. + +## Evidence required to close MN-Q02 + +- [ ] Physical Android phone manufacturer and model. +- [ ] Android and Chrome versions. +- [ ] API and web commit SHAs actually tested. +- [ ] HTTPS origin and secure-context/service-worker checks. +- [ ] Sanitized subscription row before/after evidence. +- [ ] Event type, trigger time, arrival time, and delivery latency. +- [ ] Exact observed notification title and body. +- [ ] Explicit comparison with MN-D01. +- [ ] Privacy review of collapsed and expanded lock-screen views. +- [ ] Photo of the notification on the real Android lock screen. +- [ ] Tap destination route and sanitized destination screenshot. +- [ ] Cleanup confirmation. + +None of the real-device evidence boxes are checked in this run. diff --git a/docs/notifications/reviews/desktop-browser-push-verification.md b/docs/notifications/reviews/desktop-browser-push-verification.md new file mode 100644 index 0000000000..cdac516311 --- /dev/null +++ b/docs/notifications/reviews/desktop-browser-push-verification.md @@ -0,0 +1,111 @@ +# MN-Q01 – Desktop browser push verification + +## Result + +**Blocked — not passed.** No successful push delivery or notification +click-through was observed in Chrome, Edge, or Firefox during this verification +attempt on 23 August 2026. + +This report records the evidence that was collected and the precise work still +needed. It must not be treated as browser sign-off. The blocker was access to +controllable sessions in the required browsers, not a demonstrated OnTrack code +defect. + +## Environment + +- OnTrack web: `feature/notifications` at `60b9ab7af` +- OnTrack api: `feature/notifications` at `564ff793f` +- Host: macOS 26.6.2 (25G83), Apple silicon +- Google Chrome: 151.0.7922.139 +- Microsoft Edge: 151.0.4129.101 +- Mozilla Firefox: 147.0.4 +- Test origin: `http://localhost:4200` + +The browser versions were read from the installed application bundles. Each +required browser was installed, but an installed browser is not evidence that +the push flow ran successfully in it. + +## Preflight evidence + +The local notification stack was started from the notification feature branches +and checked before attempting browser verification: + +| Check | Observed result | +| -------------------------------------- | --------------- | +| Web app at `http://localhost:4200/` | HTTP 200 | +| Api endpoint | HTTP 200 | +| `http://localhost:4200/ngsw-worker.js` | HTTP 200 | +| `PushNotificationService.configured?` | `true` | +| `push_subscriptions` table present | `true` | +| Existing push subscription count | `0` | + +These results show that the app, api, service-worker asset, VAPID configuration, +and database table were available. They do not prove that any browser subscribed +or received a push. The zero subscription count confirms that no subscription +was created during this run. + +## Browser results + +| Browser | Permission and subscription | Real event delivery | Click-through | Screenshot | Result | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------- | ------------- | ---------- | ------------------------ | +| Chrome 151.0.7922.139 | Not run. Browser control reported that Chrome was unavailable. | Not observed | Not tested | None | **Blocked / not passed** | +| Edge 151.0.4129.101 | Not run. Browser control reported that Edge was unavailable. | Not observed | Not tested | None | **Blocked / not passed** | +| Firefox 147.0.4 | Not run. Firefox was not supported by the available browser-control session and no controllable Firefox session was available. | Not observed | Not tested | None | **Blocked / not passed** | + +The in-app browser was used only as a diagnostic. Its notification permission +was already `denied`; the Profile push control was disabled and no subscription +was created. It is not Chrome, Edge, or Firefox, so that result does not satisfy +this ticket for any of the three required browsers. + +There are deliberately no notification screenshots attached to this report: +no required browser produced a notification. Likewise, no real event delivery +or notification click-through is claimed. + +## Rerun procedure + +Run the following sequence separately in Chrome, Edge, and Firefox. Do not carry +a subscription, service-worker cache, or permission result from one browser into +another browser's evidence. + +1. Check out the current `feature/notifications` branches for both web and api, + start the local stack, and record the web and api commit hashes. +2. Confirm that the web app, api, and `/ngsw-worker.js` each return HTTP 200. +3. In the api container, confirm that `PushNotificationService.configured?` is + `true` and that the `push_subscriptions` table exists. +4. Reset notification permission for `http://localhost:4200` to **Ask**, clear + the origin's service worker and site data, then reload. Also confirm that + macOS allows the browser application to show notifications and that Focus is + off. +5. Sign in as the intended recipient, wait for the service worker to register, + open Profile, and use the push opt-in control. Grant the native notification + permission when prompted. +6. Record `Notification.permission`, service-worker registration state, and the + push-subscription rows before and after opt-in. Verify that exactly the + expected user's subscription was added or updated. +7. From a second authorised account, trigger a real notification event such as + posting a task comment for the subscribed recipient. Do not use a DevTools + synthetic push because it does not test api delivery. +8. With the recipient tab unfocused, verify that the operating-system + notification appears. Capture a screenshot showing the browser, operating + system, timestamp, title, and privacy-safe body. +9. Click the notification and verify that OnTrack focuses or opens and navigates + to the event's expected route. Record the actual route and capture evidence. +10. Check api logs for push delivery failures and confirm whether the + subscription row remains present. Record any browser-console or api error + verbatim in the result table. +11. Sign out or remove the test subscription before moving to the next browser, + then repeat from step 4 in a fresh browser profile. + +## Completion criteria for the follow-up run + +MN-Q01 can be signed off only when all three browser rows contain: + +- a granted permission and persisted subscription; +- a visible notification produced by a real OnTrack event; +- a successful click-through to the intended page; and +- dated screenshot evidence, with any private information redacted. + +If a browser fails after preflight succeeds, create a separate bug containing +the browser version, reproduction steps, service-worker state, subscription +evidence, api log excerpt, expected result, and actual result. Until that run is +completed, this ticket remains blocked and must not be marked passed. diff --git a/docs/notifications/reviews/recipient_amplification_risk.md b/docs/notifications/reviews/recipient_amplification_risk.md new file mode 100644 index 0000000000..68f2d48b0f --- /dev/null +++ b/docs/notifications/reviews/recipient_amplification_risk.md @@ -0,0 +1,530 @@ +# EN-S04 – Recipient and Email Amplification Risk Review + +## Purpose + +This review looks at the recipient and email amplification risks for notification events EN-V01 to EN-V08. + +The main question for each event is: + +- Who should actually receive the notification? + +- Can one user action cause several internal updates? + +- If it can, how many emails could that generate? + +- Is there already a guard in place? + +- If not, what kind of guard should be added when the event is implemented? + +This is a review task only. No production notification code was changed as part of EN-S04. + +The review was completed against the current `feature/notifications` branch of `doubtfire-api`. + +--- +## Main amplification risks found + +While reviewing the notification paths, I found three places where one logical action can result in several internal operations. + +### 1. Unit date changes + +`Unit#propogate_date_changes_to_tasks` runs when a unit start date changes. + +It loops through the unit's task definitions and calls: + +`td.propogate_date_changes date_diff` + +`TaskDefinition#propogate_date_changes` then changes the task dates and saves the TaskDefinition. + +This means one unit date change can save many TaskDefinitions. + +If a due-date email was attached directly to a general TaskDefinition update callback, one unit-level change could accidentally generate emails for every affected task and every eligible student. + +If there are `T` task definitions and `S` eligible students, the worst-case fan-out could be approximately: + +`T × S emails` + +The current EN-V01 implementation avoids this by raising the event from the normal task-definition update API rather than from a generic model callback. + +--- +### 2. Moving a group between tutorials + +`Group#switch_to_tutorial` processes every project in the group. + +For each project it temporarily calls: + +`remove_member(proj, notify: false)` + +and later: + +`add_member(proj, notify: false)` + +These membership changes are internal steps needed to move the group. They are not real group leave/join events. + +Without the `notify: false` guard, a group with `N` members could receive: + +`N removal emails + N addition emails` + +or: + +`2N false emails` + +from one tutorial move. + +The current implementation correctly suppresses these temporary notifications. + +--- +### 3. Group task transitions + +`Task#create_submission_and_trigger_state_change` loops across every task in a group submission. For the original task, `Task#trigger_transition` can also call `GroupSubmission#propagate_transition`, which calls `trigger_transition(... group_transition: true ...)` for every other task. + +For `N` member tasks, a notification attached blindly to every transition invocation could therefore see up to: + +`1 + (N - 1) + (N - 1) = 2N - 1 transition invocations` + +The first term is the original task, the second is the propagation pass, and the third is the remaining calls from the outer group-task loop. Only `N` member tasks exist, and some repeated calls may be no-ops, but a new event still needs both an actual-status-change check and an original-action guard. + +The existing `group_transition` flag can distinguish the original action from propagated or internal calls. Raising a group-submission notification once at the group boundary is safer still. + +--- + +# EN-V01 – Task due date changed + +## Trigger + +The current implementation raises this event from: + +`app/api/task_definitions_api.rb` + +After the normal task-definition update, the API checks whether `due_date` actually changed and queues: + +`TaskDueDateChangedNotificationJob` + +The notification is deliberately not attached to every TaskDefinition save. + +## Recipient + +The intended recipients are eligible students affected by that task. + +The current job filters based on the active unit, enrolment and target grade. The existing task-notification preference is then handled through the notification system. + +## Worst case + +For one directly changed task and `S` eligible students: + +`S emails` + +This is expected because the change affects the cohort. + +The more dangerous case would be a unit date change affecting `T` tasks, which could become: + +`T × S emails` + +if the notification was attached to every TaskDefinition save. + +## Existing guard + +The current API-level trigger avoids that cascade. + +The job also checks that the queued due date is still current, which helps avoid stale notifications if the date changes again before the job runs. + +## Recommendation + +Keep this event attached to the normal task-definition update workflow. + +Do not move it to a generic TaskDefinition lifecycle callback. + +If students need to be notified about a bulk unit schedule change in the future, a separate unit-level notification or digest would be safer than one email for every changed task. + +--- +# EN-V02 – New task available + +## Trigger + +The implementation queues: + +`NewTaskAvailableNotificationJob` + +after a `TaskDefinition` is successfully created through the normal API, CSV +task import or unit rollover/copy workflow. Bulk workflows enqueue only after +the task definition is fully populated. + +A generic `TaskDefinition after_create` callback is not used. + +## Recipient + +The intended recipients are students for whom the new task is actually available. + +The current job checks things such as: + +- active unit; + +- current enrolment; + +- target-grade eligibility; + +- effective task start date; and + +- task notification preference. + +## Worst case + +For one new task and `S` eligible students: + +`S emails` + +This is expected. + +If a bulk operation creates `T` tasks, the fan-out can become: + +`T × S emails` + +from one import. This is intentional when all `T` rows are new tasks, but it is +kept off the request thread and updated rows are excluded. + +## Existing guard + +The implementation uses explicit post-workflow triggers rather than a generic +model callback. The API, import and rollover requests only queue Sidekiq jobs; +they do not deliver cohort email inline. + +It reserves an immutable task-definition key for the student under a unique +database index. This protects against concurrent fan-outs, retries, task +renames and the scheduled future-date check without confusing a genuinely new +definition that reuses an abbreviation. + +## Recommendation + +Keep each workflow trigger explicit and best-effort. + +Do not replace it with a generic `after_create` callback. + +Keep bulk fan-out in Sidekiq, enqueue only genuinely new imported/copied tasks, +and retain the effective-date and duplicate guards. + +--- +# EN-V03 – Task due soon + +## Trigger + +There is no model update that naturally happens when a deadline becomes close, so EN-V03 uses: + +`SendDueSoonRemindersJob` + +The job is scheduled through `config/schedule.yml`. + +## Recipient + +The job considers students with outstanding eligible tasks whose actual due date falls within the reminder window. + +It uses the existing due-date calculation rather than simply reading the raw TaskDefinition date. + +## Worst case + +A scheduled run can legitimately find many student/task combinations. + +If `S` students each have `T` eligible outstanding tasks inside the reminder window, the theoretical fan-out can approach: + +`S × T reminders` + +This is expected scheduled workload rather than amplification from a single convenor action. + +## Existing guard + +Before sending, the job checks whether that student/task already has a `task_due_soon` notification. + +The intended behaviour is therefore: + +`one reminder per student per task` + +Running the job again should not send the same reminder again. + +## Recommendation + +Keep the duplicate check. + +The schedule should not be made unnecessarily frequent because each newly matched student/task pair can result in an email. + +The schedule entry should also not be described as fully verified in development until the required Sidekiq worker is available. + +--- +# EN-V04 – Tutorial changed + +## Proposed trigger + +The relevant method is `Project#enrol_in`. + +A matching enrolment in the requested tutorial is a no-op. A newly created row, however, does not always mean a first-time enrolment. When a project has more than one tutorial enrolment and is moved to a tutorial without a stream, `Project#enrol_in` destroys the existing enrolments, sets the local enrolment to `nil`, and creates a replacement row. + +For that reason, EN-V04 must compare the project's effective tutorial IDs before and after the operation. It must not use `tutorial_enrolment.nil?` by itself to decide that this is a first-time enrolment. + +## Recipient + +The correct recipient is `project.student`. + +Only the student whose effective tutorial membership changed should receive the notification. The whole old tutorial or new tutorial must not be used as the recipient list. + +## Worst case + +A normal one-student move should generate `1 email`. + +A real group tutorial move involving `N` students can legitimately generate `N tutorial-change emails`, one for each student whose effective tutorial membership changed. + +## Risk + +The main recipient risk is notifying everyone in the old or new tutorial rather than only the affected students. + +There is also a state-detection risk: the multiple-enrolment consolidation path creates a replacement row even though it represents a genuine tutorial move. + +## Recommendation + +Capture the project's tutorial IDs before and after `Project#enrol_in`, and raise EN-V04 only when there was at least one prior tutorial enrolment and the effective tutorial set genuinely changed. + +Do not notify for: + +- a genuine first enrolment; +- selecting the same tutorial again; or +- internal cleanup that leaves the effective tutorial membership unchanged. + +Keep the recipient as the affected project's student. Review bulk and import callers separately before allowing them to generate student emails. + +--- + +# EN-V05 – Group membership changed + +## Trigger + +The current implementation uses: + +`Group#add_member` + +and: + +`Group#remove_member` + +## Recipient + +The current scope is student-only. + +The recipient is the student whose membership changed. + +Other members of the group are not notified. + +## Worst case + +A normal direct addition should generate: + +`1 email` + +A normal direct removal should generate: + +`1 email` + +The important amplification case is `Group#switch_to_tutorial`. + +Without a guard, a group with `N` members could receive: + +`2N false group membership emails` + +because every student is temporarily removed and added again. + +## Existing guard + +The current tutorial-switch path calls both membership methods with: + +`notify: false` + +This correctly prevents the internal remove/add operations from becoming real notification events. + +The current implementation also avoids broadcasting the event to every member of the group. + +## Stale member risk + +The notification uses the project involved in the current add/remove operation rather than walking old GroupMembership records. + +This reduces the risk of former or inactive members receiving a notification about a later membership change. + +## Recommendation + +Keep the existing `notify: false` guard for internal operations. + +The event should continue to notify only the student whose membership changed unless the team explicitly decides that group-wide notifications are required. + +Bulk membership changes should also remain explicitly controlled rather than inheriting notification behaviour automatically. + +--- +# EN-V06 – Student submitted for marking + +## Proposed trigger + +This event overlaps with `Task#trigger_transition` because a student submission moves a task into a ready-for-marking or feedback state. + +EN-E02 already uses this transition area for task status notifications, so EN-V06 must not add another notification call blindly without checking the existing behaviour. + +## Recipient + +The intended recipient is the authorised tutor returned by `project.tutor_for(task_definition)`. + +The implementation must safely handle a missing recipient. Before implementation, the team should also record whether a cross-tutorial group has one responsible tutor or should notify each distinct authorised tutor. It must not silently assume that every member project resolves to the same tutor. + +## Amplification risk + +Group submissions are the important case. + +`Task#create_submission_and_trigger_state_change` loops across all `N` member tasks. The original task's `Task#trigger_transition` can also call `GroupSubmission#propagate_transition`, which calls `trigger_transition(... group_transition: true ...)` for the other `N - 1` tasks. + +A notification attached to every transition invocation could therefore see up to: + +`2N - 1 transition invocations` + +This consists of the original call, `N - 1` propagation calls, and `N - 1` remaining calls from the outer group-task loop. Only `N` member tasks exist and some repeated calls may be no-ops, but an event sent without an actual-status-change guard could still duplicate. + +## Recommendation + +The cleanest option is to raise EN-V06 once from the group-submission boundary rather than once from every member task. + +If the notification remains inside `Task#trigger_transition`, require all of the following: + +- the task genuinely changed into the submitted or ready-for-feedback state; +- the call represents the original action, with `group_transition: false`; and +- duplicate protection prevents the same logical submission from notifying the same authorised tutor twice. + +Record the lead-approved tutor rule for cross-tutorial groups. A tutor receiving many independent submissions is a separate volume concern and may support a later digest, but it is not the same as duplicate amplification. + +--- + +# EN-V07 – Portfolio submission received + +## Proposed trigger + +The portfolio submission path writes: + +`project.portfolio_submission_date = Time.zone.now` + +in: + +`app/api/projects_api.rb` + +The reviewed branch does not currently contain a `portfolio_received` event. + +## Existing portfolio emails + +The existing `PortfolioEvidenceMailer` contains: + +- `portfolio_ready` + +- `portfolio_failed` + +These describe what happened after portfolio generation. + +They are different from a confirmation that the student's submission itself was received. + +## Recipient + +The intended recipient should be: + +`project.student` + +## Worst case + +A normal accepted portfolio submission should generate: + +`1 confirmation email` + +The risk is repeated requests or retries generating multiple receipt emails for the same logical submission. + +## Recommendation + +Only raise the receipt notification when a genuine portfolio submission is accepted. + +The implementation should prevent a retry or repeated request for the same submission from creating another receipt, while still allowing a later genuine resubmission to receive its own confirmation. + +The exact deduplication mechanism should be agreed with the lead before implementation. + +EN-V07 should also remain separate from the existing `portfolio_ready` and `portfolio_failed` emails because they represent different stages of the portfolio process. + +--- +# EN-V08 – Discussion or check-in booked + +## Scope finding + +This event cannot currently be implemented as written. + +The reviewed API does not contain a booking model, appointment model or calendar booking table that represents a future discussion booking. + +The existing discussion/check-in related models represent things that have already happened rather than a future appointment. + +For example, the existing discussed-comment path records a discussion that has already taken place. + +## Recipient + +There is no reliable recipient or trigger to review until the event itself is redefined. + +## Recommendation + +Do not force EN-V08 onto an unrelated model or invent a booking concept. + +The replacement event needs to be agreed with the lead first. + +One possible replacement mentioned in the ticket is a notification when a discussion prompt is raised for a student, but that should only be implemented if the team agrees that this is the intended replacement. + +If no replacement is agreed, closing or rescoping EN-V08 is the correct outcome. + +--- +# Risk Summary + +| Event | Expected fan-out from one action | Main risk | Current/recommended guard | +|---|---:|---|---| +| EN-V01 – Due date changed | `S` | Unit date propagation could become `T × S` | Keep API-level trigger; avoid generic TaskDefinition callback | +| EN-V02 – New task | `S` | Bulk creation/import can become `T × S` | Queue explicit post-workflow fan-outs; exclude updated rows; retain date and duplicate guards | +| EN-V03 – Due soon | Up to `S × T` per scheduled sweep | Same reminder being sent every run | Existing duplicate check: one reminder per student/task | +| EN-V04 – Tutorial changed | `1` normally, `N` for a real group move | Whole-tutorial recipients or misclassifying the replacement-row path | Compare effective tutorial IDs before and after; notify only genuinely changed students | +| EN-V05 – Group changed | `1` normally | Tutorial switch could create `2N` false emails | Existing `notify: false` guard | +| EN-V06 – Submitted for marking | `1` intended; up to `2N - 1` transition invocations | A per-call hook can duplicate one group submission | Notify once at the group boundary, or require a real change and `group_transition: false`; record the tutor rule | +| EN-V07 – Portfolio received | `1` intended | Duplicate confirmation after retry/repeated request | Send once per genuine submission occurrence | +| EN-V08 – Discussion booked | N/A | No booking concept exists | Rescope before implementation | + +--- +# General recommendations + +From this review, the main rule I would follow for the remaining v2 notification work is: + +**A notification should represent one meaningful user-facing event, not every internal model operation needed to complete that event.** + +In particular: + +1. Use the project/student directly affected by the event instead of building unnecessarily broad recipient lists. + +2. Avoid generic lifecycle callbacks where the same model is also changed by imports, rollovers, propagation or maintenance operations. + +3. Use existing context flags such as `group_transition` when an internal update needs to be distinguished from the original action. + +4. Keep bulk operations explicit. A CSV import or group-wide change should not start emailing large numbers of people simply because it happens to call the same model method as an individual action. + +5. Use duplicate protection for jobs that may be retried or scheduled repeatedly. + +6. Check current membership/enrolment state rather than using historical relationships when deciding recipients. + +7. Where an event has no matching domain action, as with EN-V08, rescope it rather than forcing a notification onto the wrong hook. + +--- +# Conclusion + +The review confirmed that the biggest email amplification risks are caused by internal cascades rather than by the email templates themselves. + +The three clearest examples are: + +- a unit date change updating many TaskDefinitions; + +- a group tutorial move temporarily removing and re-adding every member; and + +- a group task submission propagating the same transition across several tasks. + +The current EN-V01, EN-V02, EN-V03 and EN-V05 work already contains useful safeguards against these problems. + +For the remaining events, the most important protections are to keep recipients narrow, distinguish real user actions from internal propagation, and prevent repeated execution from generating duplicate email. + +EN-V08 should remain unimplemented until the team agrees on a replacement event because the current API does not contain a discussion-booking concept. + +Overall, the safest pattern is: + +**one logical event → the intended current recipient(s) → no extra email just because the action caused several internal records to change.** diff --git a/docs/notifications/reviews/v2-push-lock-screen-risk.md b/docs/notifications/reviews/v2-push-lock-screen-risk.md new file mode 100644 index 0000000000..53ffdcde32 --- /dev/null +++ b/docs/notifications/reviews/v2-push-lock-screen-risk.md @@ -0,0 +1,115 @@ +# MN-S04 – v2 push payload lock-screen sign-off + +## Decision + +**Sign-off status: APPROVED for the payload policy in this branch.** + +All eight v2 event bodies are safe to display on a locked device. The four +events whose richer in-app messages contain a free-form name or precise +schedule data now use reviewed, event-keyed Web Push copy. Email and in-app +notifications retain their useful detail after sign-in. + +This review covers the current EN-V01, EN-V02, EN-V03 and EN-V05 +implementations on `feature/notifications`, plus the candidate EN-V04, EN-V06, +EN-V07 and EN-V08 branches. The candidate event branches had not merged when +the review was completed. Their sign-off depends on retaining the event names +recorded below so the lock-screen override is applied. + +## Applied rule + +A push notification must be safe when anyone near a locked phone can read it. +The visible title is the configured product name, **OnTrack** in this +deployment. The body must contain no person name, comment, feedback, mark, +grade, free-form label, task name, precise schedule, or exact action time unless +that field has received an explicit lock-screen privacy approval. + +`PushNotificationService` still caps the body at 400 characters. Truncation is +a transport limit, not redaction. Safety comes from selecting reviewed copy +before truncation. + +## Event findings + +| Event | Event name | Final lock-screen body | Finding | Reason | +| --------------------------------- | ---------------------------- | ------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------- | +| EN-V01 – Task due date changed | `task_due_date_changed` | `The due date for in has changed.` | **Pass** | Bounded academic identifiers; no date, person, result, feedback, or free-form content. | +| EN-V02 – New task available | `new_task_available` | `A new task is available: in .` | **Pass** | Bounded academic identifiers; no personal or assessment detail. | +| EN-V03 – Task due soon | `task_due_soon` | ` in is due soon.` | **Pass** | No exact deadline or student-specific progress. | +| EN-V04 – Tutorial changed | `tutorial_changed` | `Your tutorial details changed.` | **Pass** | The override omits the tutorial label, unit, meeting day, and meeting time. | +| EN-V05 – Group membership changed | `group_membership_changed` | `Your group membership changed.` | **Pass** | The override omits the free-form group name, unit, and add/remove direction. | +| EN-V06 – Submitted for marking | `task_submitted` | `A task is ready for marking.` | **Pass** | The tutor sees no student name, task name, unit, or product interpolation on the lock screen. | +| EN-V07 – Portfolio received | `portfolio_received` | `Your portfolio submission was received.` | **Pass** | The override omits the exact submission time and timezone while preserving the receipt meaning. | +| EN-V08 – Discussion prompt ready | `discussion_request_created` | `A discussion prompt is ready for you.` | **Pass** | No tutor, student, task, unit, audio, or prompt content. | + +## How the unsafe payloads were corrected + +`PushNotificationService::LOCK_SCREEN_BODY_OVERRIDES` owns the reviewed bodies +for EN-V04 through EN-V07. `payload_for` selects that copy by the persisted +event name before applying `MAX_BODY_LENGTH`. + +This location is deliberate: + +- `Notification#message` remains the rich in-app and email message. +- Web Push never receives the group name in EN-V05 or the student/task names in + EN-V06. +- Rebuilding a payload from a persisted notification produces the same safe + body; no transient caller argument can be lost or bypassed. +- Unrelated events continue to use their existing notification message. + +Focused service tests construct every overridden event with a unique sensitive +canary and assert both the exact approved body and the canary's absence. The +EN-V05 model test separately proves that its email still contains the rich +message while its push body is generic. + +## Payload fields outside the visible body + +The payload also carries a collapse tag, `notification_id`, a validated +internal click route, and Angular's click action. The service worker does not +render these values as lock-screen text. The route allowlist still matters for +navigation safety, but a safe route is not being used as a substitute for safe +visible copy. + +The encrypted payload transits a third-party push service operated by the +browser vendor. The reviewed bodies reveal only the broad event type. Endpoint +and encryption keys are transport metadata supplied separately to that service; +they are not copied into the notification JSON. + +## Email and in-app consistency + +The shorter push copy preserves the meaning of the richer EN-V05 and EN-V06 +email/in-app messages without repeating private detail on a shared screen: + +- EN-V05 still tells the signed-in student which membership changed. +- EN-V06 still tells the signed-in tutor which student and task are waiting. +- EN-V04 and EN-V07 keep schedule and receipt details in authenticated or email + contexts while push communicates only that the event occurred. + +This is intentional channel-specific copy, not a contradiction between +channels. + +## Rule gaps and follow-up guardrails + +The original rule named comments, feedback, marks, and grades but did not fully +classify free-form labels, schedule metadata, or exact action times. Apply these +additions going forward: + +1. Treat every free-form value as unsafe for push by default. +2. Treat names and precise schedule/action timestamps as unsafe unless a + documented privacy decision approves them. +3. Give email, in-app, and lock-screen copy separate fields in the event + documentation template. +4. Add a negative payload test whenever an event's rich message contains a + person name, free-form value, assessment detail, schedule, or exact time. +5. Re-run this review if any event name changes, because the override is keyed + by that stable name. + +## Sign-off checklist + +- [x] Applied the MN-S02 lock-screen rule without weakening it. +- [x] Reviewed all eight v2 event bodies. +- [x] Reviewed the tutor-facing EN-V06 payload specifically. +- [x] Replaced unsafe V05 and V06 bodies with channel-specific copy. +- [x] Resolved V04 and V07 conservatively rather than exposing schedule/time + metadata. +- [x] Preserved richer email and in-app meaning. +- [x] Added automated negative checks for every override. +- [x] Recorded the remaining rule gaps and follow-up guardrails. diff --git a/docs/notifications/reviews/v2-push-lock-screen-wording.md b/docs/notifications/reviews/v2-push-lock-screen-wording.md new file mode 100644 index 0000000000..c4254282d3 --- /dev/null +++ b/docs/notifications/reviews/v2-push-lock-screen-wording.md @@ -0,0 +1,93 @@ +# MN-D05 – v2 Push Lock-Screen Wording + +## Decision + +Use the configured product name as the title for every v2 push notification. +The current OnTrack deployment therefore shows `OnTrack` (7 characters). +Keep the body separate from the richer email and in-app message so a locked +device never exposes details that are only needed after the recipient opens +OnTrack. + +## Approved wording + +| Ticket and event | Event name | Push title | Push body | Body length | Privacy and email alignment | +| -------------------------------------- | ---------------------------- | ----------------------- | ---------------------------------------------------------------------- | --------------------------------------------------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| EN-V01 – Task due date changed | `task_due_date_changed` | Configured product name | `The due date for {{task_abbreviation}} in {{unit_code}} has changed.` | 34 + identifier lengths; 46 for `1.1P` / `COS10001` | Says that the due date changed without exposing the old or new date, a student, progress, or assessment content. The email can direct the student to the authenticated task for the new date. | +| EN-V02 – New task available | `new_task_available` | Configured product name | `A new task is available: {{task_abbreviation}} in {{unit_code}}.` | 30 + identifier lengths; 42 for `1.1P` / `COS10001` | Identifies the available task without a person, result, feedback, or task content. It has the same meaning as the richer email and task link. | +| EN-V03 – Task due soon | `task_due_soon` | Configured product name | `{{task_abbreviation}} in {{unit_code}} is due soon.` | 17 + identifier lengths; 29 for `1.1P` / `COS10001` | Gives a neutral reminder without an exact deadline, progress state, mark, or feedback. The email can direct the student to the authenticated task for details. | +| EN-V04 – Tutorial changed | `tutorial_changed` | Configured product name | `Your tutorial details changed.` | 30 | Deliberately omits the tutorial label, unit, meeting day, and meeting time. It preserves the EN-D05 meaning while leaving the new schedule to the richer email and authenticated unit page. | +| EN-V05 – Group membership changed | `group_membership_changed` | Configured product name | `Your group membership changed.` | 30 | Covers both the added and removed email variants without exposing the free-form group name, unit, other members, or the direction of the change. | +| EN-V06 – Task submitted for marking | `task_submitted` | Configured product name | `A task is ready for marking.` | 28 | This tutor-facing body intentionally omits the student's name and the potentially free-form task name. The EN-D06 email can identify the student and task for the assigned tutor; the lock screen must not. | +| EN-V07 – Portfolio submission received | `portfolio_received` | Configured product name | `Your portfolio submission was received.` | 39 | Preserves the EN-D06 receipt meaning without exposing the exact submission time or timezone. The richer receipt email remains the source for those details. | +| EN-V08 – Discussion prompt ready | `discussion_request_created` | Configured product name | `A discussion prompt is ready for you.` | 37 | Omits the tutor, task, unit, audio, and prompt content. It matches the proposed discussion-prompt event and does not falsely claim that a discussion was booked. | + +Character counts include spaces and punctuation. The three example counts use +the identifiers already exercised by the push payload tests. + +## Length and truncation + +`PushNotificationService::MAX_BODY_LENGTH` limits the API body to 400 +characters. That ceiling protects the Web Push payload; it is not a display +guarantee. Android, iOS, desktop browsers, device settings, font size and +notification layout can all truncate earlier, at different points. + +The fixed bodies are between 28 and 39 characters. EN-V01 to EN-V03 retain the +existing concise task abbreviation and unit code. Their exact length varies +with those identifiers, so the essential event wording must remain concise and +no private information may be placed later in the body in the hope that it +will be hidden by truncation. + +## Lock-screen privacy rule + +Assume the device is locked, face up and visible to someone other than the +recipient. A push title or body must not contain: + +- a student, staff member, or other person's name; +- marks, grades, results, progress, feedback, comments or submission content; +- free-form group, task, tutorial, prompt or uploaded content; +- an exact class schedule or action timestamp unless separately approved for + lock-screen display. + +Task abbreviations and unit codes remain in EN-V01 to EN-V03 because the +existing lock-screen review classified those bounded academic identifiers as +acceptable for these events. The conservative EN-V04 and EN-V07 decisions +avoid extending that approval to schedule metadata or exact submission times. + +## Email and in-app separation + +The approved push bodies describe the same event as the EN-D05 and EN-D06 +email copy but intentionally say less: + +- EN-V04 email can give the new tutorial schedule; push only says it changed. +- EN-V05 email can explain whether the student joined or left a named group; + push only says the membership changed. +- EN-V06 email can identify the student and task to the assigned tutor; push + only says work is ready. +- EN-V07 email can act as a timestamped receipt; push only confirms receipt. + +Changing `Notification#message` would also remove useful detail from email and +the in-app notification. The MN-S04 implementation therefore applies +`PushNotificationService::LOCK_SCREEN_BODY_OVERRIDES` for `tutorial_changed`, +`group_membership_changed`, `task_submitted` and `portfolio_received` while +leaving the stored message and richer channels unchanged. EN-V01 to EN-V03 and +the rescoped EN-V08 body already match the approved lock-screen wording. + +## EN-V08 scope caveat + +OnTrack currently has no discussion-booking or appointment record. The EN-V08 +candidate branch instead raises `discussion_request_created` when a tutor's +audio discussion prompt is ready. This wording is approved only for that +rescope. A future real booking event needs its own wording and lock-screen +review; it must not reuse this event name or claim that a prompt is a booking. + +At the time of this review, EN-V04, EN-V06, EN-V07 and EN-V08 were candidate +branches rather than part of `feature/notifications`. The exact event-name +mapping above is the contract those branches must retain when they merge. + +## Documentation follow-up + +`docs/notifications/events/_template.md` currently records email copy but has +no home for channel-specific push wording. Add `Push title` and `Push body` +fields to that template under EN-D03 so each future event is reviewed before it +inherits Web Push delivery. This ticket records the recommendation but does +not change the shared template. diff --git a/docs/notifications/reviews/web-push-browser-device-support.md b/docs/notifications/reviews/web-push-browser-device-support.md new file mode 100644 index 0000000000..6774b58da6 --- /dev/null +++ b/docs/notifications/reviews/web-push-browser-device-support.md @@ -0,0 +1,116 @@ +# Web Push support by browser and device + +MN-D04. Support position checked 23 August 2026. + +## Answer first: can an iPhone receive OnTrack push? + +Yes, on iOS or iPadOS 16.4 or later, but only from a Home Screen web app. The +user must add OnTrack to the Home Screen, open that installed app instead of an +ordinary browser tab, tap OnTrack's enable control, and grant notification +permission. This applies whether Safari, Chrome, Edge, or Firefox added the web +app: Apple made Add to Home Screen available to third-party browsers, and the +installed web app runs separately from the browser that added it. + +An iPhone user who only opens OnTrack in a browser tab cannot receive its Web +Push notifications. MN-W01's installable manifest and MN-W03's visible iOS +installation instructions are therefore prerequisites, not optional polish. + +## Requirements common to every supported entry + +OnTrack delivery needs all of the following: + +1. A secure context. Production needs HTTPS; `http://localhost` is a special + development exception. A phone opening a laptop's plain HTTP LAN address is + not a secure context. +2. An active service worker and the Push and Notifications APIs. +3. A direct user action on OnTrack's enable control, followed by permission from + the browser or operating system. +4. A VAPID-backed `PushSubscription` stored by the authenticated + `/api/push_subscriptions` endpoint. +5. Permission for the browser or installed web app at operating-system level. +6. A subscription endpoint whose host passes OnTrack's server allowlist. + +Installation is not normally required for desktop or Android Web Push. It is a +hard requirement on iOS and iPadOS. + +## Browser and platform matrix + +"Supported" describes the browser platform, not a completed OnTrack device +test. MN-Q01 and MN-Q02 own observed delivery evidence. + +| Browser | Platform | Web Push | What it requires | Important catch for OnTrack | +| ------- | ---------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Chrome | Desktop | Supported | Current Chrome, secure context, service worker, user-granted notification permission | Chrome subscriptions normally use `fcm.googleapis.com`, which OnTrack accepts. The browser may need to remain allowed to run in the background for delivery after its windows close. | +| Chrome | Android | Supported | Current Chrome on Android and the common requirements above; installation is optional | Uses Google's push infrastructure. OnTrack accepts current `fcm.googleapis.com` and legacy `android.googleapis.com` endpoints. Android may separately mute Chrome or the site. | +| Chrome | iOS/iPadOS | Supported only as an installed Home Screen web app on 16.4+ | Add from Chrome's Share menu, open from the Home Screen, then enable push from a user gesture | No push from a normal Chrome tab. The installed app uses Apple's Web Push path, not Chrome/FCM. Its endpoint must match OnTrack's Apple allowlist. | +| Edge | Desktop | Supported | Current Microsoft Edge, secure context, service worker, VAPID, and user permission | Depending on platform/version, an endpoint may use Google or Microsoft infrastructure. OnTrack accepts FCM, `*.push.services.microsoft.com`, and legacy `*.notify.windows.com`. Capture the actual host in MN-Q01. | +| Edge | Android | Supported by the current Edge PWA platform; OnTrack not yet device-verified | Current Edge on Android and the common requirements; installation is optional | Microsoft documents PWA capabilities across devices but does not promise which push-service host a given mobile build returns. OnTrack will reject a new host until it is reviewed and allowlisted. | +| Edge | iOS/iPadOS | Supported only as an installed Home Screen web app on 16.4+ | Add from Edge's Share menu, open the installed app, then enable push from a user gesture | No push from an ordinary Edge tab. Apple's Home Screen web-app rules and endpoint compatibility apply. | +| Firefox | Desktop | Supported | Current Firefox, secure context, service worker, and user-granted permission | Firefox uses Mozilla's push service. OnTrack accepts `updates.push.services.mozilla.com`. Firefox must be running for desktop delivery according to Mozilla's user documentation. | +| Firefox | Android | Supported | Current Firefox for Android, site notification permission, Android notification permission, and the common requirements | Mozilla routes Firefox Android Web Push through its service plus Google Cloud Messaging. The subscription endpoint still needs to be one OnTrack accepts; record it during device testing. | +| Firefox | iOS/iPadOS | Supported only through an installed Home Screen web app on 16.4+ | Add from the Share menu, open the installed web app, then enable push from a user gesture | Do not treat Firefox's ordinary iOS tab as the receiver. Once installed, the Home Screen web app is a separate WebKit app and uses Apple's Web Push path. | +| Safari | Desktop | Supported on macOS Ventura with Safari 16.1 or later | Secure context, service worker, standards-based Web Push/VAPID, and user permission | No Apple Developer Program membership is required. OnTrack accepts Apple's documented `*.push.apple.com` endpoint namespace. | +| Safari | Android | Not available | Safari is not released for Android | Use Chrome, Edge, or Firefox and verify the endpoint host. | +| Safari | iOS/iPadOS | Supported only as an installed Home Screen web app on 16.4+ | Share → Add to Home Screen, open the installed app, tap OnTrack's enable control, then grant permission | This is the key platform limitation. No installed app means no Push API permission prompt and no delivery. Focus, Lock Screen, and per-app notification settings can still suppress display. | + +## OnTrack's endpoint allowlist is a second compatibility gate + +Browser support alone is not enough. `PushSubscription` rejects an endpoint +unless its HTTPS host is one of these exact hosts: + +- `fcm.googleapis.com` — Chrome and Chromium-family delivery. +- `android.googleapis.com` — older Chrome on Android. +- `updates.push.services.mozilla.com` — Firefox. + +It also accepts subdomains ending in: + +- `.notify.windows.com` — legacy Windows Notification Service endpoints. +- `.push.services.microsoft.com` — current Microsoft push-service endpoints. +- `.push.apple.com` — Safari and iOS/iPadOS Web Push endpoints. + +The suffix check includes the leading dot, so a lookalike such as +`evil-notify.windows.com` does not pass. Delivery repeats the check for rows +created before the model validation existed. + +This is deliberately stricter than "the browser implements Push API". A new +browser version can support Web Push and still receive HTTP 400 from OnTrack if +its vendor starts returning a host outside this list. Record the endpoint host +in every manual browser/device test. Review a new host against vendor +documentation before adding it; never broaden the validation just to make a +test pass. + +Apple endpoints are matched with the boundary-aware `.push.apple.com` suffix. +That accepts hosts in Apple's documented namespace without accepting lookalikes +such as `evilpush.apple.com` or `web.push.apple.com.example.org`. + +## What this table does not claim + +- `.browserslistrc` says which JavaScript targets Angular compiles for. It is + not evidence that a browser/OS can subscribe, receive, display, and navigate + from a push. +- API presence or a successful subscription is not delivery evidence. The + operating system can mute an otherwise valid subscription. +- iOS browser branding is not a way around the Home Screen rule. +- An Android emulator is useful for layout and permission-flow checks, but does + not satisfy MN-Q02's requirement for a real-phone Lock Screen delivery. + +## Primary sources + +- Apple WebKit, [Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) — iOS/iPadOS 16.4, Home Screen requirement, direct user interaction, Lock Screen delivery, Apple push service, and third-party Add to Home Screen. +- Apple WebKit, [Meet Web Push](https://webkit.org/blog/12945/meet-web-push/) — standards-based Web Push in Safari on macOS Ventura and no Apple Developer Program requirement. +- Apple WebKit, [WebKit Features in Safari 18.4](https://webkit.org/blog/16574/webkit-features-in-safari-18-4/) — confirms standard Web Push shipped in Safari 16.1 on macOS and iOS/iPadOS 16.4. +- Google web.dev, [Push notifications overview](https://web.dev/articles/push-notifications-overview) — permission, subscription, service-worker, browser push-service, and FCM endpoint flow. +- Microsoft Edge, [Re-engage users with push messages](https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps/how-to/push) — Edge permission, Push API, VAPID, user-visible requirement, and service-worker delivery. +- Microsoft Edge, [Use Progressive Web Apps in Microsoft Edge](https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps/ux) — current device-wide PWA capability position. +- Mozilla Support, [Web Push notifications in Firefox](https://support.mozilla.org/en-US/kb/push-notifications-firefox) — Firefox desktop delivery, Mozilla push service, permissions, and Android routing. +- Mozilla Support, [Manage notifications in Firefox for Android](https://support.mozilla.org/en-US/kb/manage-notifications-firefox-android) — Android site-notification permission control. +- Mozilla Source Docs, [Push](https://firefox-source-docs.mozilla.org/dom/push/) — Firefox and Firefox for Android implementation paths. +- MDN, [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) and [Secure contexts](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) — platform API and secure-context baseline. + +## Follow-up verification + +Use `testing-push-locally.md`, then record for each tested row: operating system, +browser version, secure-context result, permission state, returned endpoint host, +API row, real event, displayed title/body, click destination, and cleanup. A +failure should become its own bug with reproduction steps rather than being +hidden in this comparison. diff --git a/docs/notifications/testing-push-locally.md b/docs/notifications/testing-push-locally.md new file mode 100644 index 0000000000..77e265211a --- /dev/null +++ b/docs/notifications/testing-push-locally.md @@ -0,0 +1,507 @@ +# Testing push notifications locally + +MN-D03. How to get a push notification to arrive on your own machine, and then on +a real phone. + +This does not cover generating VAPID keys, registering a browser by hand, or what +the payload looks like. That is all in +[`push-setup.md`](./push-setup.md), in this same folder. Read that first. This document is only about the two +things it does not answer: why push works on `localhost` with no HTTPS, and why it +stops working the moment you point a phone at your laptop. + +Every section is marked **Verified** or **Not tested**. Read those marks. The +tunnel half of this guide has not been walked end to end by anyone yet, so treat +section 3 as a route rather than a proven path and correct it as you go. + +--- + +## 0. Push does nothing without MN-F03 + +**Verified** — read from web#4, `push/enable-service-worker-in-dev`, merged into +`feature/notifications` in `doubtfire-web` on 8 Aug 2026. + +A service worker is the thing that receives a push. Before MN-F03, `angular.json` +set `"serviceWorker": "ngsw-config.json"` only on the `production` build +configuration, so a development build never generated `ngsw-worker.js` at all. +The browser had nothing to receive a push with, and the failure was silent. + +MN-F03 changed two files: + +```diff + "development": { + "optimization": false, + "extractLicenses": false, +- "sourceMap": true ++ "sourceMap": true, ++ "serviceWorker": "ngsw-config.json" + }, +``` + +```diff + ServiceWorkerModule.register('ngsw-worker.js', { +- enabled: environment.production, ++ enabled: environment.production || environment.enableServiceWorker, + registrationStrategy: () => interval(6000).pipe(take(1)), + }), +``` + +plus `enableServiceWorker: true` in `src/environments/environment.ts`. + +**If you are on a branch that does not have MN-F03, nothing in this guide will +work and you will get no error telling you why.** Check before you start: + + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4200/ngsw-worker.js + +200 means you have it. 404 means you do not. Full detail, including the six second +registration delay and the `$NODE_ENV` trap, is in +`docs/service-worker.md` in the `doubtfire-web` repo, on `feature/notifications`. + +--- + +## 1. `http://localhost` is a secure context, so localhost needs no HTTPS + +**Verified** — behaviour recorded in `doubtfire-web/docs/service-worker.md` on +2026-08-02, where a push sent from the api arrived as a desktop notification on +`http://localhost:4200`. I did not re-run it for this guide. + +Service workers and the Push API are restricted to secure contexts. People read +that as "I need HTTPS" and go and generate a self-signed certificate, or set up +mkcert, or ask why the dev stack does not do TLS. **None of that is necessary.** + +The rule is not "must be HTTPS". It is "must be a *potentially trustworthy +origin*", and loopback addresses are on that list by definition. So all of these +are secure contexts: + +- `http://localhost:4200` +- `http://localhost` on any port +- `http://127.0.0.1:4200` +- `http://[::1]:4200` + +Which means the ordinary dev stack, `ng serve` on port 4200 over plain HTTP, is +already good enough to register a service worker, subscribe to push, and receive +a push. Do this part first. It is the fast loop, and if push does not work here it +will not work anywhere else either. + +Order of work: + +1. `curl` `/ngsw-worker.js` and get a 200 (section 0). +2. Follow `push-setup.md` to confirm the keys are loaded and subscribe the browser. +3. Trigger an event, get a notification on your desktop. + +Only once that works should you go anywhere near a tunnel. + +--- + +## 2. The trap: a phone on your wifi is not a secure context + +**Verified on a physical phone.** MN-Q03 reproduced this on an iPhone 16 +running iOS 26.6 on 13 Aug 2026. The phone reached the api over the LAN +address, but the push opt-in remained disabled because the page was not a +secure context. + +`angular.json` sets the dev server to `"host": "0.0.0.0"`, so `ng serve` listens +on every interface, not just loopback. Your laptop's LAN address works. You can +type `http://192.168.1.42:4200` into a phone on the same wifi and the OnTrack app +loads, logs in, and behaves completely normally. + +**And push will not work, and nothing will tell you why.** + +`192.168.1.42` is not a loopback address. It is a plain HTTP origin like any +other, so it is not a secure context, so `navigator.serviceWorker` is not even +defined on that page. Practically: + +- No service worker registers. +- `SwPush.isEnabled` is `false`, so MN-C01's opt-in button reports "not + supported" rather than an error. +- `Notification.requestPermission()` may still work, which makes it look like + permissions are the problem when they are not. +- Nothing appears in the console. There is no exception, no warning, no failed + request. The feature is just absent. + +This is the evening-costing one. The symptom is "it works on my laptop but not on +my phone", and the instinct is to go and debug the subscription code, the VAPID +key, the api logs, notification permissions on the phone. All of that is fine. The +page is simply not a secure context. + +Quick check, in the phone's browser console or as a bookmarklet: + +```js +console.log(window.isSecureContext, 'serviceWorker' in navigator); +``` + +`false false` on the LAN address, `true true` through a tunnel. If you only take +one thing from this document, take that line. + +**Testing on a real phone therefore needs real HTTPS, which means a tunnel.** + +### iOS is a second trap on top of the first + +**Not tested.** Documented Safari behaviour, included because it will come up. + +Safari on iOS only supports Web Push for web apps that have been added to the +Home Screen. Opening the tunnel URL in Safari and expecting a push will fail even +over HTTPS. The user has to Share → Add to Home Screen and open it from there. +Android Chrome has no such restriction. If you are picking a phone to test with, +pick Android. + +--- + +## 3. Tunnel setup with cloudflared + +**Not tested.** `cloudflared` is not installed on this machine and I have not run +any of this. The steps below are written from the tool's documented behaviour and +from configuration I did verify in our repos (each config change is marked +separately). Treat the sequence as a first draft that needs someone to walk it. + +I picked `cloudflared` over `ngrok` because a quick tunnel needs no account, no +signup and no authtoken. `ngrok` now requires an account before it will forward +anything. One tool, done properly, rather than two done badly. + +### Why one tunnel is enough + +**Verified** — read from `doubtfire-web/src/app/config/constants/hostUrl.ts` and +`doubtfire-web/proxy.conf.json`. + +The obvious worry is that you need two tunnels, one for the web app on 4200 and +one for the api on 3000, and that the phone would load an HTTPS page that then +tries to call `http://localhost:3000` and gets blocked as mixed content. + +That does not happen, because of two things already in the repo: + +```ts +// src/app/config/constants/hostUrl.ts +const HOST_URL: string = `${window.location.protocol}//${window.location.hostname}${window.location.port ? ':' + window.location.port : ''}`; +``` + +The app derives its api base URL from wherever the page was loaded from. It is not +hardcoded. And `package.json` runs `ng serve ... --proxy-config proxy.conf.json`, +which forwards `/api` to the api container: + +```json +{ "/api": { "target": "http://localhost:3000", "secure": false } } +``` + +So the browser only ever talks to one origin. Tunnel port 4200 and the api comes +along with it. **Do not tunnel port 3000 as well.** It will not help and it gives +you a second hostname to get wrong. + +### Step 1 — install cloudflared + + brew install cloudflared + +### Step 2 — have the stack running on localhost first + +Section 1. If push does not work on `http://localhost:4200`, a tunnel will not fix +it, it will just add a second thing that can be broken. + +### Step 3 — start the tunnel + + cloudflared tunnel --url http://localhost:4200 + +It prints a hostname that looks like: + + https://random-words-here.trycloudflare.com + +That hostname is new every time you restart the tunnel. Which matters, because +both config changes below name it, so **you will be editing config every time you +restart the tunnel.** Leave it running. + +### Step 4 — let the Angular dev server answer to that hostname + +**Verified** that this option exists and is spelled this way — read from +`node_modules/@angular/build/src/builders/dev-server/schema.json` at version +22.0.4. Not verified that the tunnel then works. + +Vite, which is what `@angular/build:dev-server` runs on, rejects requests whose +`Host` header is not in its allowlist. Without this you get a Vite "Blocked +request" page through the tunnel instead of the app. + +In `angular.json`, under `projects.doubtfire.architect.serve.options`: + +```json +"serve": { + "builder": "@angular/build:dev-server", + "options": { + "buildTarget": "doubtfire:build", + "port": 4200, + "host": "0.0.0.0", + "allowedHosts": ["random-words-here.trycloudflare.com"] + }, +``` + +Changing `angular.json` does not update the already-running dev server. Restart +the web service before opening the tunnel hostname. Run this from +`doubtfire-deploy/development`: + + docker compose -f docker-compose.yml -f docker-compose.local-paths.yml \ + restart doubtfire-web + +If the frontend is running directly with `npm start`, stop it and start it again +instead. + +The schema also accepts `"allowedHosts": true` to allow everything. Its own +description calls that "not recommended and a security risk", which is fair, since +your dev server is on the public internet for as long as the tunnel is up. Use it +if you are restarting the tunnel constantly and are sick of editing this file, but +do not commit it. + +**Do not commit any of this.** It is a hostname that will not exist tomorrow. + +### Step 5 — let the api answer to that hostname + +**Verified** — I reproduced the failure directly against the running api +container: + +``` +$ curl -s -H "Host: probe-test.trycloudflare.com" http://localhost:3000/api/settings +Blocked hosts: probe-test.trycloudflare.com +To allow requests to these hosts, make sure they are valid hostnames (containing +only numbers, letters, dashes and dots), then add the following to your +environment configuration: +config.hosts << "probe-test.trycloudflare.com" +``` + +The same request with the default `Host` returns 200. + +This is Rails 8 Host Authorization, not CORS. In development Rails allows loopback +addresses, any raw IP, and anything ending in `.localhost` or `.test`. A +`trycloudflare.com` hostname is none of those, so the api rejects it before any +of our code runs. + +The fix that needs no code change, in `doubtfire-deploy/development/docker-compose.yml` +under `doubtfire-api.environment`: + +```yaml + RAILS_DEVELOPMENT_HOSTS: 'random-words-here.trycloudflare.com' +``` + +**Verified** that Rails reads this variable and splits it on commas — railties +8.0.2, `lib/rails/application/configuration.rb`. Comma separate if you need more +than one. + +Then recreate the container. `restart` does not pick up new environment +variables, same as with the VAPID keys: + + docker compose -f docker-compose.yml -f docker-compose.local-paths.yml up -d doubtfire-api + +Confirm it took: + + curl -s -o /dev/null -w "%{http_code}\n" \ + -H "Host: random-words-here.trycloudflare.com" \ + http://localhost:3000/api/settings + +### About CORS, which is not your problem + +**Verified** — read from `doubtfire-api/config/application.rb:287`. + + config.middleware.insert_before Warden::Manager, Rack::Cors do + allow do + origins '*' + resource '*', headers: :any, methods: %i(get post put delete options) + end + end + +Origins is already `*`. **There is no CORS change to make for a tunnel.** And +because of the dev server proxy in step 3, the api calls are same-origin anyway, +so CORS is not even in play. If you are looking at a CORS error you have found a +different bug. The api-side change you actually need is `RAILS_DEVELOPMENT_HOSTS` +above. + +### An alternative to step 5 that I have not tried + +**Not tested.** Setting `"changeOrigin": true` on the `/api` entry in +`proxy.conf.json` should make the proxy rewrite the `Host` header to +`localhost:3000` before forwarding, so Rails never sees the tunnel hostname and +`RAILS_DEVELOPMENT_HOSTS` becomes unnecessary. That would survive tunnel restarts, +which is the appeal. + +I did not test it, and I did not confirm what the proxy's default actually is. +`RAILS_DEVELOPMENT_HOSTS` is the one I verified fails and can be made to pass, so +that is what step 5 says. If you try `changeOrigin`, note that in the Docker stack +`doubtfire-deploy/development/docker-compose.local-paths.yml` mounts +`proxy.conf.docker.json` over the repo's `proxy.conf.json` read-only, so you have +to edit the deploy repo's copy, not the web repo's. + +### Step 6 — open it on the phone + +Open the printed `https://...trycloudflare.com` URL on the phone. Check the secure +context first, before anything else: + +```js +window.isSecureContext && 'serviceWorker' in navigator +``` + +Before subscribing on the phone, record the existing rows: + + docker exec doubtfire-api bundle exec rails runner \ + 'puts PushSubscription.order(:id).map { |s| "#{s.id} #{s.user.username} #{s.endpoint[0, 80]}" }' + +Then sign in, wait out the six second service worker registration delay, and use +MN-C01's opt-in button. Run the same command again. The new or changed row is +the phone's subscription. + +Do not identify the device only from the endpoint host. It narrows the browser +but it does not name the device, and Chromium does not imply one service: +measured on 27 Aug 2026, Chrome 152 subscribed through `fcm.googleapis.com` and +Edge 151 on macOS through `wns2-bl2p.notify.windows.com`. Firefox uses Mozilla +Push and Safari/iOS uses Apple Web Push. Comparing the rows before and after +subscribing is the reliable check. Then trigger an event as another user and +watch. + +### Things that will probably go wrong + +**Not tested.** Written from what the configuration implies, not from experience. + +- **Vite's HMR websocket may not survive the tunnel.** The page still loads, live + reload just stops. Not worth fixing for a push test, reload by hand. +- **The tunnel hostname changes on every restart**, and both step 4 and step 5 + name it. If push worked yesterday and does not today, check that first. +- **A quick tunnel is public.** Anyone with the URL reaches your dev stack with + its throwaway VAPID keys and seeded database. Stop it when you are done. +- **You will have two subscriptions for your user**, one from the desktop test and + one from the phone. Both get pushed. That is correct behaviour, not a bug. + +--- + +## 4. Clearing a stuck service worker + +**Not tested** in Firefox. The Chrome console snippet is taken from +`doubtfire-web/docs/service-worker.md`, which is where the fuller writeup of the +service worker's caching side effects lives. + +A service worker caches the whole app bundle and keeps serving it. The symptom is +that you change code, reload, and still see the old code. A hard reload does not +help, because the worker still intercepts the request. + +### Both browsers, from the console + +Works in Chrome and Firefox. Faster than the UI and the one to reach for at 1am: + +```js +(await navigator.serviceWorker.getRegistrations()).forEach((r) => r.unregister()); +const keys = await caches.keys(); +await Promise.all(keys.map((k) => caches.delete(k))); +location.reload(); +``` + +### Chrome, through dev tools + +1. F12 → **Application** → **Service Workers**. +2. **Unregister** next to `ngsw-worker.js`. +3. **Application** → **Storage** → **Clear site data**. +4. Reload. + +While you are actively working on the app, **Application → Service Workers → +Bypass for network** stops the worker serving cached responses without +unregistering it. That is usually what you want during normal development. It is a +per-devtools-session setting and it resets when you close dev tools. + +`chrome://serviceworker-internals` lists every registration in the profile and +will unregister them, which is the one to use when a worker is stuck on an origin +you no longer have open. + +### Firefox, through dev tools + +1. F12 → **Application** → **Service Workers**. +2. **Unregister**. +3. **Storage** → right click the origin → **Delete All**. +4. Reload. + +`about:debugging#/runtime/this-firefox` is the equivalent of Chrome's +`serviceworker-internals` and has **Unregister** buttons per worker. + +Firefox private windows do not run service workers at all, so push cannot work +there. Do not use one to test. + +--- + +## 5. Resetting notification permission + +**Not tested.** Written from the current browser UIs. Someone should walk these +and correct them. + +Permission is per origin, and once denied the browser will not ask again. The +opt-in button will report "blocked" forever and no amount of clicking will +prompt. You have to reset it by hand. Everyone denies it by accident once. + +Check where you stand, in the console: + +```js +Notification.permission // "default" | "granted" | "denied" +``` + +`default` means you will be prompted. `denied` means you will not. + +### Chrome + +Fastest: click the icon at the left of the address bar (the tune or lock icon), +find **Notifications**, set it back to **Ask (default)**. Reload. + +Or `chrome://settings/content/notifications`, find the origin under **Not allowed +to send notifications**, and remove it. Whole-origin nuke, which also clears the +service worker and everything else, is **Clear site data** in the same panel. + +Two things that are not the same as the browser permission and get confused with +it: + +- **macOS System Settings → Notifications → Google Chrome.** If Chrome itself is + not allowed to post notifications, the browser permission can be `granted` and + the push can arrive and you still see nothing. `push-setup.md` calls this out + too. Check it once, then stop thinking about it. +- **Focus / Do Not Disturb.** Same result, notifications delivered silently to + Notification Centre. + +### Firefox + +Click the padlock in the address bar → **Clear cookies and site data**, or expand +**Connection secure** → **More information** → **Permissions**, find **Receive +Notifications**, and untick **Use Default** then set it back. + +Or `about:preferences#privacy` → **Permissions** → **Notifications** → +**Settings**, find the origin, **Remove Website**. Reload. + +Check `about:preferences#privacy` → **Notifications** → **Settings** for **Block +new requests asking to allow notifications** as well. If that is ticked, nothing +will ever prompt and the state will read `denied` on every site. + +### Android Chrome + +Site permissions are under the padlock → **Permissions** → **Notifications**. But +also check Android **Settings → Apps → Chrome → Notifications**, because if +Chrome as an app is blocked at the OS level then no site inside it can post +anything, and the in-page permission will still say `granted`. + +--- + +## Verification status, all in one place + +| Section | Status | +|---|---| +| MN-F03 is required, and what it changed | **Verified.** Read from the web#4 diff, merged 8 Aug 2026. | +| `http://localhost` is a secure context | **Verified.** Recorded working in `doubtfire-web/docs/service-worker.md`, 2026-08-02. Not re-run here. | +| A LAN address is not a secure context | **Verified on a physical phone.** iPhone 16 / iOS 26.6, 13 Aug 2026, MN-Q03. The phone reached the api over the LAN address but the opt-in button was disabled with "This browser does not support push notifications". | +| iOS needs Add to Home Screen | **Not tested.** Documented Safari behaviour. | +| One tunnel is enough, because of `hostUrl.ts` + the dev server proxy | Config **verified** by reading it. The conclusion is an inference, **not tested**. | +| `cloudflared` install and tunnel steps | **Not tested.** `cloudflared` is not installed on this machine. | +| `allowedHosts` is a real dev server option | **Verified** against `@angular/build` 22.0.4's schema. Effect through a tunnel **not tested**. | +| Rails blocks a tunnel hostname | **Verified.** Reproduced with `curl -H "Host: ..."` against the running api. | +| `RAILS_DEVELOPMENT_HOSTS` is the variable Rails reads | **Verified** in railties 8.0.2 source. Not tested end to end through a tunnel. | +| No CORS change is needed | **Verified.** `origins '*'` at `config/application.rb:287`. | +| `changeOrigin` as an alternative | **Not tested.** Offered as a lead, not a step. | +| Clearing a service worker | Console snippet from `service-worker.md`. Chrome and Firefox UI paths **not tested**. | +| Resetting notification permission | **Not tested**, all browsers. | + +Section 2 has now been walked. MN-Q03 hit exactly the failure this guide +predicts, on an iPhone 16 running iOS 26.6 on 13 Aug 2026. The phone reached the +api fine over the LAN address and the opt-in button was still disabled, which is +the browser refusing to expose the push API outside a secure context. So the +rule holds on real hardware and not just on paper. + +Section 3, the tunnel, is still unwalked. Nobody has yet run `cloudflared` end +to end and got a push onto a phone. That is what MN-Q02 and the retest of MN-Q03 +are for, and a screenshot of a notification arriving on a real lock screen is +the deliverable that closes them. + +If you are the first person through section 3, correct this document as you go +rather than working around it. Every step in there was reasoned from config +rather than executed, and the marks in the table above say which is which. diff --git a/docs/peer-progress-api.md b/docs/peer-progress-api.md new file mode 100644 index 0000000000..7d37d64eac --- /dev/null +++ b/docs/peer-progress-api.md @@ -0,0 +1,217 @@ +# Student Peer Progress API + +## Route and authorisation + +`GET /api/projects/:id/task_def_id/:task_definition_id/peer_progress` + +The route is restricted to the authenticated student who owns the active, +enrolled project. The unit and target grade are derived on the server. The task +must belong to that unit, be applicable to the student's target grade, and be +released for that project. The browser cannot select a peer cohort. + +Every response has `Cache-Control: private, no-store`. + +## HTTP 200 response contract + +Authorised business states use the same allowlisted response shape: + +| Field | Type | Nullable | Meaning | +| --- | --- | --- | --- | +| `task_definition_id` | Integer | No | Requested task definition. | +| `unit_id` | Integer | No | Unit derived from the authenticated project. | +| `target_grade` | Integer | Yes | Valid server-derived grade, or `null`. | +| `submitted_percentage` | Number | Yes | Compatibility metric: other students with a task file upload, quantised to 10-point buckets. | +| `completed_percentage` | Number | Yes | Compact-display metric: other students whose snapshot status is exactly `complete`, quantised to 10-point buckets. | +| `status_distribution` | Array | Yes | Ordered, quantised full-lifecycle distribution, or `null` when it cannot safely be released. | +| `distribution_available` | Boolean | No | Whether `status_distribution` is present. | +| `distribution_unavailable_reason` | String | Yes | Safe machine reason when detailed data is absent. | +| `is_suppressed` | Boolean | No | The entire aggregate is hidden because the cohort is below the configured floor. | +| `is_stale` | Boolean | No | The stored snapshot is older than the configured window. | +| `is_feature_enabled` | Boolean | No | Whether the unit has enabled peer progress. | +| `is_user_enabled` | Boolean | No | The authenticated user's saved `display_peer_progress` preference. | +| `last_updated_at` | String | Yes | Snapshot time as UTC ISO 8601, or `null`. | +| `unavailable_reason` | String | Yes | Safe machine reason when compact data is absent. | +| `unavailable_message` | String | No | Empty on compact success; otherwise neutral user-facing copy. | + +Normal response example: + +```json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": 60.0, + "completed_percentage": 10.0, + "status_distribution": [ + { "status": "not_started", "percentage": 20.0 }, + { "status": "complete", "percentage": 10.0 }, + { "status": "need_help", "percentage": 0.0 }, + { "status": "working_on_it", "percentage": 20.0 }, + { "status": "fix_and_resubmit", "percentage": 10.0 }, + { "status": "feedback_exceeded", "percentage": 0.0 }, + { "status": "redo", "percentage": 10.0 }, + { "status": "discuss", "percentage": 0.0 }, + { "status": "ready_for_feedback", "percentage": 20.0 }, + { "status": "demonstrate", "percentage": 0.0 }, + { "status": "fail", "percentage": 10.0 }, + { "status": "time_exceeded", "percentage": 0.0 }, + { "status": "assess_in_portfolio", "percentage": 0.0 }, + { "status": "attention_required", "percentage": 0.0 }, + { "status": "rediscuss", "percentage": 0.0 } + ], + "distribution_available": true, + "distribution_unavailable_reason": null, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "is_user_enabled": true, + "last_updated_at": "2026-08-24T03:15:00Z", + "unavailable_reason": null, + "unavailable_message": "" +} +``` + +The 15 status entries are always ordered by canonical `TaskStatus` ID: + +1. `not_started` +2. `complete` +3. `need_help` +4. `working_on_it` +5. `fix_and_resubmit` +6. `feedback_exceeded` +7. `redo` +8. `discuss` +9. `ready_for_feedback` +10. `demonstrate` +11. `fail` +12. `time_exceeded` +13. `assess_in_portfolio` +14. `attention_required` +15. `rediscuss` + +A missing task row counts as `not_started`. Each enrolled project contributes +to exactly one stored status for a task. Before any public calculation, the API +subtracts the authenticated student's project, status, and upload contribution. +All percentages therefore describe other students, never a cohort containing +the viewer. + +## Availability reasons + +`unavailable_reason` is one of: + +- `user_disabled` +- `feature_disabled` +- `target_grade_unavailable` +- `snapshot_unavailable` +- `insufficient_cohort` +- `aggregation_incomplete` +- `stale` + +`distribution_unavailable_reason` repeats the applicable compact reason, or is: + +- `detailed_data_unavailable` when a pre-migration/incomplete snapshot has no + valid lifecycle aggregate; +- `privacy_protection` when compact metrics are safe but the combined detailed + vector is not safe to release. + +The API never states which status caused detailed privacy suppression. + +## Privacy and quantisation + +Raw whole-cohort size, exact uploaded count, completed count, and per-status +counts are internal-only. They are never included in the student response. +`PeerProgressViewerPolicy` first subtracts the authenticated viewer from all +three exact aggregates. The privacy floor and every quantisation/policy check +then run over the remaining peers. + +`DF_PPI_MINIMUM_COHORT_SIZE` must be at least +`PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE` (`21`). Cohorts below the configured +value return `is_suppressed: true` and no percentages or distribution. The +configured minimum is a **remaining-peer** floor: with the default of 21, a +stored cohort needs at least 22 active projects including the viewer. Empty and +small peer cohorts use the same response state. + +Public percentages are independently rounded to the nearest 10 percentage +points. With 21 remaining peers, a single compact bucket maps to at least two +possible peer counts. Because the viewer is absent from those counts, their +knowledge of their own status or upload cannot collapse that ambiguity. +Therefore `0.0` does not prove no peer is in a state, and `100.0` does not prove +every peer is. + +Independent buckets are not sufficient for a multi-status histogram because +the buckets can constrain one another. Before returning `status_distribution`, +`PeerProgressDistributionPolicy` assumes the observer already knows the exact +cohort size and computes the feasible raw-count range for every status given all +15 buckets and the requirement that counts sum to the cohort. The whole vector +is returned only if every status retains at least two feasible raw counts. +Otherwise the vector is withheld with `privacy_protection`; compact metrics can +remain available. + +Because each status is independently quantised, a public distribution is a +visual estimate and its percentages are not generally guaranteed to sum to 100. + +## User preference + +`users.display_peer_progress` is `true` by default and non-null for new and +existing users. It is exposed by `Entities::UserEntity`, including authentication +responses, and can be saved through the normal profile endpoint: + +```http +PUT /api/users/:id +Content-Type: application/json + +{ + "user": { + "display_peer_progress": false + } +} +``` + +When false, the peer-progress endpoint returns `is_user_enabled: false`, reason +`user_disabled`, and no peer metrics. Saving `true` re-enables it. + +## Freshness and grade changes + +`DF_PPI_STALE_AFTER_HOURS` must be a positive integer. A stale snapshot returns +no metrics. Each project records `target_grade_changed_at`; a snapshot calculated +before the current target-grade selection is treated as unavailable until the +next aggregation. + +Missing or invalid configuration fails closed with HTTP 503 once an enabled +user, unit, valid grade, and snapshot require the configuration. + +If the viewer's persisted task changed after `snapshot.calculated_at`, the API +returns `snapshot_unavailable` rather than subtracting a current status/upload +from an older aggregate. Snapshots created before exact `submitted_count` and +15-status data were introduced return `aggregation_incomplete` until the next +aggregation. + +## Error responses + +- `200`: authorised normal, preference-off, disabled, suppressed, stale, or + otherwise unavailable business state. +- `404`: the project/task cannot safely be exposed to this caller. Unknown IDs, + wrong ownership, wrong role, inactive enrolment/unit, unreleased tasks, and + inapplicable tasks share the same message. +- `419`: authentication failed through the existing OnTrack flow. +- `503`: required peer-progress configuration is missing or invalid. + +## Demo data + +The all-features demo is triple-guarded: Rails development, database exactly +`doubtfire-all-features-demo`, and `DF_DEMO_DATA_PROFILE=all-features`. + +`db:all_features_demo` creates a 25-student total cohort (24 remaining peers for +the demo viewer) with visible `not_started`, +`working_on_it`, `ready_for_feedback`, `fix_and_resubmit`, `redo`, `complete`, +and `fail` states. It uses the production aggregation service. + +`db:all_features_demo_verify` is read-only and fails unless the preference and +unit feature are enabled, the task is released, the snapshot is fresh and leaves +enough peers after viewer subtraction, the true completed metric is available, +and the same production viewer/public policies release all 15 status keys with +the seven showcase states visible. + +`db:ppi_sample_data` creates the larger two-unit dashboard dataset under the +same guards and validates every generated snapshot with the same distribution +policy. diff --git a/docs/peer-progress/data-source-map.md b/docs/peer-progress/data-source-map.md new file mode 100644 index 0000000000..00ac4657a3 --- /dev/null +++ b/docs/peer-progress/data-source-map.md @@ -0,0 +1,178 @@ +# Peer Progress Indicator — Backend Data-Source Map + +**Ticket:** PPI-D02 — Publish the peer-progress backend data-source and field-ownership map +**Status:** Updated for the production API PR #60 contract. +**Builds on:** [PPI API discovery](./task-completion-data-discovery.md) — the earlier starter task that +located existing task-completion data (`Task`, `TaskStatus`, `Project#task_stats`, +`Unit#student_task_completion_stats`) and found it was not reachable by students. This document goes +one level deeper: it maps the current backend implementation against the agreed PPI response contract, +field by field, and records what is still open. + +## Implementation status + +PPI-B01 was developed on `ppi/student-progress-endpoint` and merged into the shared +`feature/peer-progress-indicator` branch through API PR #16 (merge commit `1e011b12`). The source branch +has since been deleted. Everything marked "available" below is therefore available on the shared +objective branch; the PR #16 head (`91d4db95`) remains the useful review snapshot for the implementation. + +This document records that baseline plus the additive detailed lifecycle, +completion, privacy, and profile-preference work in API PR #60. + +--- + +## 1. Backend data-source table + +| File | Class / method | Branch | Role | +|---|---|---|---| +| `app/api/peer_progress_api.rb` | `PeerProgressApi` (Grape API), `get '/projects/:id/task_def_id/:task_definition_id/peer_progress'` | `feature/peer-progress-indicator` (PPI-B01, merged via #16) | Student-facing endpoint. Authorises the request, looks up the stored snapshot, applies suppression/staleness rules, returns the allowlisted response. | +| `app/models/peer_progress_snapshot.rb` | `PeerProgressSnapshot` | API PR #60 | One row per `(unit, task_definition, target_grade)`. Stores internal whole-cohort `cohort_size`, exact `submitted_count`, all 15 raw `status_counts`, compatibility `submitted_percentage`, and `calculated_at`. Validates exact counts fit/cover the cohort. Raw counts never cross the student API boundary. | +| `app/services/peer_progress_aggregation_service.rb` | `PeerProgressAggregationService.call(unit:, calculated_at:)` | API PR #60 | Batch job logic. For each grade value, counts uploads and every canonical current task status. A missing task row contributes to `not_started`, so each cohort member contributes exactly once per task. | +| `app/services/peer_progress_viewer_policy.rb` | `PeerProgressViewerPolicy.build`, `.public_metrics` | API PR #60 | Subtracts the authenticated viewer's project, exact upload contribution, and status before applying the remaining-peer floor, compact quantisation, or detailed-vector policy. Fails closed if the viewer project/task changed after the snapshot or an exact aggregate is incomplete. | +| `app/services/peer_progress_distribution_policy.rb` | `PeerProgressDistributionPolicy` | API PR #60 | Defines the 15-key canonical order, 10-point quantisation, and the vector-wide feasible-count ambiguity check. A detailed vector is released only when every status retains at least two possible raw counts even if the observer knows the cohort size. | +| `app/sidekiq/aggregate_peer_progress_job.rb` | `AggregatePeerProgressJob#perform(unit_id = nil)` | `feature/peer-progress-indicator` (PPI-B01, merged via #16) | Scheduled dispatcher selects active, PPI-enabled units and enqueues one job per unit. Each per-unit job rechecks active/enabled state before calling the aggregation service. Scheduled via `config/schedule.yml` — `"every day at 11:45pm"`. | +| `db/migrate/20260809153000_create_peer_progress_snapshots.rb` | — | `feature/peer-progress-indicator` (PPI-B01, merged via #16) | Creates `peer_progress_snapshots` table. Comment in the migration explicitly flags `cohort_size` as "Internal only. Never expose this raw value through the student API." | +| `db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb` | — | `feature/peer-progress-indicator` (PPI-B01, merged via #16) | Adds `units.peer_progress_enabled` boolean, `default: false, null: false`. | +| `app/models/unit.rb` | `Unit#active_projects` | `feature/peer-progress-indicator` (pre-existing) | Reused as the base scope for cohort selection (`unit.active_projects.where(target_grade: â€Ļ)`). | +| `app/models/unit.rb` | `Unit#grade_value?` | `feature/peer-progress-indicator` (pre-existing) | Reused to validate a project's `target_grade` is actually a value the unit has enabled, both when aggregating and when deriving the safe target grade for a request. | +| `app/models/unit.rb` | `Unit.active_units` | `feature/peer-progress-indicator` (pre-existing) | Reused so the nightly dispatcher skips inactive units. The job further scopes this relation to `peer_progress_enabled: true`. | +| `app/models/project.rb` | `Project.for_user(user, include_inactive)` | `feature/peer-progress-indicator` (pre-existing) | Reused to authorise that the requested project actually belongs to the authenticated student. | +| `app/models/task.rb` | `Task#file_uploaded_at` | `feature/peer-progress-indicator` (pre-existing column) | The signal used to decide whether a task counts as "submitted" for aggregation — see note below, this is **not** the same signal the original discovery task found. | +| `app/models/project.rb` | `Project#target_grade_changed_at`, `#record_target_grade_change` (`before_create`/`before_update` callback) | `feature/peer-progress-indicator` (PPI-B01, merged via #16) | New column + callback. Records when a student's target grade last changed, so a snapshot calculated *before* a grade change is never shown as if it applied to the new grade. Backfill migration sets it to "now" for all existing projects — see §5. | +| `db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb` | — | `feature/peer-progress-indicator` (PPI-B01, merged via #16) | Adds `projects.target_grade_changed_at` with a retained database `CURRENT_TIMESTAMP` default for rolling-deploy compatibility, backfills existing rows to the migration run time, then applies `NOT NULL`. | +| `db/migrate/20260824000002_ensure_target_grade_changed_at_default.rb` | — | Release readiness | Idempotently restores the retained `CURRENT_TIMESTAMP` default for development or staging databases that recorded the earlier migration before its rolling-deploy fix was added. Fresh databases already satisfy the invariant, so this migration performs no schema change there. | +| `db/migrate/20260824000003_add_detailed_peer_progress.rb` | — | API PR #60 | Adds internal exact `peer_progress_snapshots.submitted_count`, `status_counts` JSON, and `users.display_peer_progress` with `default: true, null: false`. Existing snapshot rows retain null exact aggregates and fail closed until re-aggregated. | +| `app/api/units_api.rb`, `app/api/entities/unit_entity.rb` | `PUT /units/:id` accepts `peer_progress_enabled`; `UnitEntity` exposes it gated by `can_read_unit_config?` | `feature/peer-progress-indicator` (PPI-B01, merged via #16) | Convenors can toggle PPI on/off through the normal unit-update endpoint. Visibility remains staff-only, matching the "students never see raw config" pattern. | +| `app/api/users_api.rb`, `app/api/entities/user_entity.rb` | `PUT /users/:id`, `Entities::UserEntity` | API PR #60 | Persists and exposes the user's `display_peer_progress` opt-out. It defaults on; when false, the PPI endpoint returns no metrics. | + +### Divergence from the original discovery task + +The [earlier discovery](./task-completion-data-discovery.md) found `Unit#student_task_completion_stats` +and `Project#task_stats` as existing, reusable aggregation infrastructure, built on `TaskStatus.complete`. +**PPI-B01 does not reuse either of them.** It introduces a parallel, PPI-specific path instead: + +- Compatibility submission signal: `Task.where(...).where.not(file_uploaded_at: nil)`. +- Compact completion signal: the current status is exactly `TaskStatus.complete`. +- Advanced signal: one mutually exclusive count for each of all 15 canonical statuses. +- Storage: a new `PeerProgressSnapshot` table, calculated nightly, not the ad-hoc per-request + `Unit#student_task_completion_stats` calculation. + +This looks like a deliberate design choice (a stored nightly snapshot makes the suppression/staleness +checks in the student-facing endpoint cheap and simple), not an oversight. It's recorded here so nobody +assumes the two paths are the same thing, and so **PPI-T01** (calculation rules) has an accurate +starting point. API PR #60 now exposes both meanings explicitly rather than +labelling upload presence as task completion. + +--- + +## 2. PPI field-ownership table + +Response contract as implemented in `PeerProgressApi#peer_progress_payload` on +API PR #60. It is an additive 15-field allowlist; the canonical, deployment +contract is maintained in [`docs/peer-progress-api.md`](../peer-progress-api.md). + +| Field | Purpose | Current backend source | Available / Calculated / Missing | Transformation | Owning ticket | +|---|---|---|---|---|---| +| `task_definition_id` | Task context | Request param, validated via `unit.task_definitions.find_by(id:)` | Available | Passthrough of the validated ID | PPI-B01 | +| `unit_id` | Unit context | `project.unit_id` | Available | Passthrough | PPI-B01 | +| `target_grade` | Authorised-project target-grade lookup | `Project#target_grade`, validated through `Unit#grade_value?` inside `safe_target_grade` | Available (validated, not a raw column read) | Returns `nil` if the project has no target grade or it isn't enabled for the unit. This route accepts only `:id` and `:task_definition_id`, so a grade cannot be supplied directly to this request. However, `Project#target_grade` is student-writable through the existing project-update API: it is server-stored, not server-controlled. The timestamp guard withholds older snapshots until the next aggregation, but does not permanently bind a student to one grade band. See §5. | PPI-B01 / PPI-S01 | +| `submitted_percentage` | Anonymous peer submitted percentage | Exact internal `submitted_count`, minus the viewer's upload contribution | Calculated (batch plus request-time viewer subtraction) | Quantised to the nearest 10 points over remaining peers. The stored compatibility percentage is not used to reconstruct an exact count. Null for suppressed/stale/disabled/unavailable or legacy snapshots. | API PR #60 | +| `completed_percentage` | Truthful compact peer completion percentage | Internal `status_counts['complete']`, minus the viewer if complete | Calculated | Independently quantised to 10 points over remaining peers; `nil` for suppressed/stale/disabled/unavailable states or incomplete exact snapshots. | API PR #60 | +| `status_distribution` | Advanced full lifecycle bar | Internal exact 15-key `status_counts` | Calculated | Ordered array of `{status, percentage}`. Entire vector is `null` unless above the cohort floor and every status retains at least two feasible counts after considering all buckets together. | API PR #60 | +| `distribution_available`, `distribution_unavailable_reason` | Detailed-mode availability | Distribution privacy policy and overall state | Calculated | Reasons are neutral (`privacy_protection`, `detailed_data_unavailable`, or the applicable overall reason) and never identify a sensitive category. | API PR #60 | +| `is_suppressed` | Small-cohort suppression | Computed per-request after subtracting the viewer: `peer_cohort_size < minimum_cohort_size!` (hard floor 21) | Calculated | Raw whole/peer cohort sizes are never returned. The default requires at least 22 stored active projects so 21 other students remain. Empty and small peer cohorts share the same response. Can be true with `is_stale`. | API PR #60 / PPI-S01 | +| `is_stale` | Data freshness | Computed per-request: `snapshot.calculated_at < ENV['DF_PPI_STALE_AFTER_HOURS'].hours.ago` | Calculated | Computed once and threaded through every branch, so it can appear alongside `is_suppressed: true` in the same response — see above. | PPI-T01 (approve the freshness window) / PPI-B01 (implementation) | +| `is_feature_enabled` | Whether PPI is on for this unit | `units.peer_progress_enabled` column, `default: false`; settable via `PUT /units/:id` | Available | None | Unit-level config remains convenor-controlled for normal units. The demo-only `db:ppi_sample_data` task opts its synthetic `PPI1001` / `PPI1002` units in on both first run and rerun. See §5. | +| `is_user_enabled` | Whether this user wants PPI displayed | `users.display_peer_progress`, default true/non-null; settable via `PUT /users/:id` | Available | False gates all peer metrics even when the unit feature is enabled. | API PR #60 | +| `last_updated_at` | Snapshot freshness display | `snapshot.calculated_at.utc.iso8601` | Available when a snapshot exists, else `nil` | ISO 8601 UTC string | PPI-B01 / PPI-F01 (display formatting) | +| `unavailable_message` | Safe unavailable message | Hardcoded Ruby constants in `PeerProgressApi` (`UNAVAILABLE_MESSAGE`, etc.) | Available, but **placeholder wording** | None | PPI-D01 — user-facing wording is explicitly out of scope for PPI-B01; the current strings are implementation placeholders, not approved copy. | +| `unavailable_reason` | Safe machine-readable compact state | `PeerProgressApi#peer_progress_result` | Calculated | One of `user_disabled`, `feature_disabled`, `target_grade_unavailable`, `snapshot_unavailable`, `insufficient_cohort`, `aggregation_incomplete`, or `stale`; `null` on compact success. | API PR #60 | + +### Fields the response must never include (confirmed by code review) + +`peer_progress_payload` is an allowlist — it only ever builds the 15 public fields. Confirmed absent: +peer names, usernames, student IDs, peer project IDs, marks, feedback, individual peer task records, +raw `status_counts`, raw `cohort_size`, and submitted/completed counts. The migration comment on `cohort_size` +explicitly flags it as internal-only. This satisfies acceptance criterion 6 based on the code merged +through API PR #16. That PR received a privacy-focused independent review and corrective commit; the +dedicated PPI-S01 ticket should still decide the explicitly retained risks listed in §5 against the +merged code and deployment settings. + +--- + +## 3. Proposed / actual data-flow diagram + +```mermaid +flowchart TD + A["Authenticated student user
GET /api/projects/:id/task_def_id/:task_definition_id/peer_progress"] --> B["PeerProgressApi
authenticated? + role == student"] + B -->|"not a student / project not found"| X1["404 Not Found
(same message for all cases - avoids object enumeration)"] + B -->|ok| C["Project.for_user current_user
= authorised project/unit"] + C --> D["Task validation:
unit.task_definitions.find_by id
+ effective_task local_start_date released? (honours extensions)"] + D -->|"not found / not released"| X1 + D -->|ok| E["safe_target_grade project
= authorised-project target-grade lookup
(server-stored and student-writable elsewhere;
validated via Unit#grade_value?)"] + E -->|"nil / not applicable"| F1a["200 OK, unavailable
target_grade: null
= no valid target grade"] + E -->|valid| F["PeerProgressSnapshot lookup
by unit_id + task_definition_id + target_grade"] + + subgraph nightly ["Nightly dispatcher - AggregatePeerProgressJob (11:45pm)"] + G["Unit.active_units.where
peer_progress_enabled: true"] --> G1["enqueue one AggregatePeerProgressJob
per enabled active unit"] + G1 --> H["PeerProgressAggregationService.call"] + H --> I["Unit#active_projects.where target_grade: ...
= eligible cohort selection"] + I --> J["Task.where project in cohort
= upload count + all 15 current statuses;
missing task = not_started"] + J --> K[("PeerProgressSnapshot row
whole cohort_size + exact submitted_count,
15-key status_counts, calculated_at")] + end + + K -.snapshot read at request time.-> F + F -->|"no snapshot yet"| F1b["200 OK, unavailable
target_grade: present
= no snapshot for a valid target grade"] + F -->|found| R{"snapshot.calculated_at older than
project.target_grade_changed_at ?"} + R -->|yes| F1b + R -->|no| V["PeerProgressViewerPolicy
verify viewer project/task snapshot age;
subtract viewer cohort/upload/status"] + V --> L{"remaining peers below hard floor of 21,
or below DF_PPI_MINIMUM_COHORT_SIZE ?"} + L -->|yes| M1["200 OK
is_suppressed: true
(is_stale may ALSO be true)
= small-cohort suppression"] + L -->|no| N{"calculated_at older than
DF_PPI_STALE_AFTER_HOURS ?"} + N -->|yes| M2["200 OK
is_stale: true, all metrics null"] + N -->|no| M3["10-point compact quantisation
+ vector-wide lifecycle privacy policy"] + M3 --> M4["200 OK
submitted_percentage, completed_percentage,
optional 15-status distribution, availability metadata"] + + F1a --> O + F1b --> O + M1 --> O + M2 --> O + M4 --> O["PeerProgressIndicatorService.getIndicator
frontend adapter (PPI-F01)"] + O --> P["resolvePeerProgressState
PPI-F03 - UI state mapping"] + P --> Q["PpiWidgetComponent (f-ppi-widget)
rendered by task-dashboard
after task-submission-card"] +``` + +--- + +## 4. Safe example responses + +The canonical 15-field normal response, lifecycle order, nullability, state +reasons, preference semantics, and privacy explanation are maintained in +[`docs/peer-progress-api.md`](../peer-progress-api.md). Keeping a second JSON copy +here previously allowed the handover map to drift behind the production +contract, so this document now links to the tested source of truth. + +--- + +## 5. Confirmed status, gaps and unresolved decisions + +| # | Gap / decision | Detail | Owner | +|---|---|---|---| +| 1 | **Backend merged** | PPI-B01 merged through API PR #16 at `1e011b12`; the implementation is present on `feature/peer-progress-indicator` and the source branch was deleted. | PPI-B01 (complete) | +| 2 | **Frontend task adapter is live** | `PeerProgressIndicatorService.getIndicator(projectId, taskDefinitionId)` calls the authorised project/task route and maps the additive 15-field response. Unit, grade, mock state, and raw cohort values are not client-supplied. | PPI-F01 (implemented) | +| 3 | **Two distinct frontend PPI contracts** | `PeerProgressIndicator` / `PeerProgressIndicatorService` is the live task-level API adapter. `PeerProgressResponse` / `PeerProgressService` is the separate weekly burndown contract. They are intentionally not interchangeable; weekly demo fixtures remain separate from the live task request. | PPI-F01 / burndown API owner | +| 4 | **Production config still needs approval** | `doubtfire-deploy` 11.0.x supplies local-development values in `development/api.env` and both Compose files: `DF_PPI_MINIMUM_COHORT_SIZE=21` and `DF_PPI_STALE_AFTER_HOURS=48`. Production must supply separately reviewed values. The API rejects a cohort setting below the hard floor of 21, and the floor is coupled to the 10-point percentage bucket by tests. | PPI-T01 / PPI-S01 (approve production values) | +| 5 | **Demo sample units are privacy-floor and advanced-mode ready** | Both demo tasks remain triple-guarded. `db:all_features_demo` creates 25 total students, leaving 24 peers for the demo viewer, with seven visible lifecycle states. Read-only verify uses the production viewer and public-metrics policies. `db:ppi_sample_data` provisions at least configured peer floor + 1 total and validates public metrics for every viewer/snapshot. | API PR #60 / deploy PR #12 | +| 6 | **Placeholder wording** | `unavailable_message` strings are hardcoded in Ruby, written by whoever built PPI-B01, not reviewed for tone/wording. | PPI-D01 | +| 7 | **Detailed distribution is vector-checked** | Independent 10-point status buckets can jointly reveal exact counts even though each bucket alone is ambiguous (for example, cohort 24 split 6/18). API PR #60 therefore withholds the entire vector unless every status retains at least two feasible raw counts when all buckets and a known cohort size are considered. Compact values remain independently protected. Target-grade switching remains timestamp-gated as described below. | API PR #60 / PPI-S01 | +| 8 | **Backfill invalidates snapshots in already-running PPI environments** | `add_target_grade_changed_at_to_projects` backfills existing projects to migration time, so any snapshot calculated before that time is withheld until aggregation runs again. On the first deployment of the complete PPI migration series the snapshot table is created empty, so there is nothing to invalidate. This matters to development or staging environments that ran the earlier snapshot migration and aggregation before applying the later timestamp migration. | PPI-B01 (deploy sequencing) | +| 9 | **Suppression and staleness are not mutually exclusive** | `is_suppressed` and `is_stale` can both be `true`. The current frontend `resolvePeerProgressState` checks `isSuppressed` before `isStale`, so a suppressed-and-stale response resolves to the "hidden" UI state. PPI-F01/PPI-F03 should confirm that priority is intentional. | PPI-F01 / PPI-F03 | + +--- + +## 6. Explicitly out of scope for this document + +This document does not implement the backend endpoint (PPI-B01), the frontend adapter (PPI-F01), the +unit-level component (PPI-F02), percentage calculation rules (PPI-T01), loading/error states (PPI-F03), +the dedicated security follow-up (PPI-S01), or user-facing wording (PPI-D01). It does not create another +mock-data service or another minimal test-data task. Where this document identifies a security-relevant +boundary (§2, §5), that observation does not replace PPI-S01 sign-off on the retained risks. diff --git a/docs/peer-progress/task-completion-data-discovery.md b/docs/peer-progress/task-completion-data-discovery.md new file mode 100644 index 0000000000..4c9304dcf9 --- /dev/null +++ b/docs/peer-progress/task-completion-data-discovery.md @@ -0,0 +1,62 @@ +# PPI — Locate existing task-completion data in the API + +**Original ticket:** PPI - Locate existing task-completion data in the API (Discovery, starter task) +**Author:** Gaurav Manohar Myana +**Repo checked at the time:** `doubtfire-api`, branch `feature/peer-progress-indicator` + +> Preserved here, unedited from the original ticket deliverable, per PPI-D02's requirement to keep a +> link to the prior discovery work. See [data-source-map.md](./data-source-map.md) for how this +> compares against the actual PPI-B01 implementation found on `ppi/student-progress-endpoint`. + +## Purpose + +Find what task-completion data already exists in the API, so the Peer Progress Indicator isn't +designed around information that isn't actually available. + +## Relevant Rails models + +| Model | File | Relevant fields/notes | +|---|---|---| +| `Task` | `app/models/task.rb` | `task_status_id`, `completion_date`, `target_start_date`, `submission_date` | +| `TaskStatus` | `app/models/task_status.rb` | 15 fixed statuses (complete, working_on_it, fail, etc.) | +| `Project` (student's enrolment in a unit) | `app/models/project.rb` | `task_stats` (JSON): `{ red_pct, orange_pct, green_pct, blue_pct, grey_pct, order_scale }` — one student's own task-status mix | +| `Unit` | `app/models/unit.rb` | `#student_task_completion_stats` — cohort-wide median/min/max/quartile of completed tasks, broken down by tutorial and grade | + +## Relevant API endpoints + +| Endpoint | Access | Returns | +|---|---|---| +| `GET /projects/:id` | Authenticated user | Individual `task_stats` — **but hidden from the student themselves** (`unless: :for_student` in `ProjectEntity`) | +| `GET /units/:id/stats/task_completion_stats` | Staff only (`:download_stats`) | Cohort-wide completed-task stats (median/min/max/quartiles) by unit/tutorial/grade | +| `GET /units/:id/stats/task_completion_snapshots` | Staff only (`:download_stats`) | Historical point-in-time snapshots of status counts | + +## Data gap + +**No student-facing endpoint exposes any peer/cohort completion data**, and a student can't even see +their own `task_stats`. Confirmed in two places: + +1. `Unit.permissions` grants students only `[:get_unit]` — `:download_stats` is staff-only. +2. `ProjectEntity` explicitly excludes `task_stats` when the viewer is the student themselves. + +## Key finding + +The aggregation the PPI needs — anonymized cohort completed-task stats (median/quartiles by +tutorial/grade) — **already exists** in `Unit#student_task_completion_stats`. It does not need to be +built. It's just not reachable by students. + +## Recommended next step + +Add a new, student-authorised endpoint (e.g. `GET /units/:id/my_progress`) that returns the calling +student's own `task_stats` plus the cohort aggregate for their tutorial/grade, by reusing +`Unit#student_task_completion_stats` — without granting students the broader `:download_stats` +permission. + +## Blockers + +None. Scope was read-only exploration of the existing codebase; no production code changed. + +## What actually happened next (added retrospectively for PPI-D02) + +The recommendation above (reuse `Unit#student_task_completion_stats`) was **not** what PPI-B01 built. +See [data-source-map.md](./data-source-map.md) §1 "Divergence from the original discovery task" for +what was actually implemented instead, and why. diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md index b70119000e..92d5dc9709 100644 --- a/docs/pull_request_template.md +++ b/docs/pull_request_template.md @@ -1,35 +1,41 @@ -# Description +## Jira ticket -Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. +Ticket number or link: -Fixes # (issue) +## Summary -## Type of change +Briefly explain what you changed and why. -Please delete options that are not relevant. +## Target branch -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] This change requires a documentation update +Which shared branch should this be merged into? -# How Has This Been Tested? +Example: `feature/email-notifications` -Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration +## Testing -- [ ] Test A -- [ ] Test B +Explain how you tested the change. -# Checklist: +Include any useful commands, screenshots, logs, or test results. -- [ ] My code follows the style guidelines of this project -- [ ] I have performed a self-review of my own code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation if appropriate -- [ ] My changes generate no new warnings -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] I have created or extended unit tests to address my new additions -- [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published in downstream modules +## Security and privacy -If you have any questions, please contact @macite or @jakerenzella. +Does this change affect authentication, permissions, notifications, student data, +secrets, personal information, or privacy? + +If there is no known impact, write: `No known security or privacy impact.` + +## Evidence + +Add any screenshots, test output, diagrams, or other evidence that will help the reviewer. + +## Checklist + +- [ ] I selected the correct base branch. +- [ ] My changes match the assigned Jira ticket. +- [ ] I kept the change within the agreed scope. +- [ ] I tested my changes. +- [ ] I did not include passwords, tokens, API keys, secrets, or real student data. +- [ ] I updated relevant documentation, or no documentation change was needed. +- [ ] I reviewed my own changes before requesting review. +- [ ] This pull request is ready for review. diff --git a/docs/security/FILE-S01-security-findings.md b/docs/security/FILE-S01-security-findings.md new file mode 100644 index 0000000000..670c239688 --- /dev/null +++ b/docs/security/FILE-S01-security-findings.md @@ -0,0 +1,29 @@ +# FILE-S01 security findings and integration recommendation + +Date: 27 August 2026 + +## Disposition + +| Finding | Status | Evidence or follow-up | +| --- | --- | --- | +| Cross-project submission could create work before authorization | Verified | Exact 401 contract plus zero task, `TaskSubmission`, Sidekiq-job and storage deltas | +| Client extension or declared MIME could bypass content validation | Verified | API and `FileHelper` rejection tests assert exact MIME/extension outcomes | +| Unsafe ZIP paths or resource-amplifying archives | Verified | Traversal, entry-count, compression-ratio and total-uncompressed-size tests | +| A later duplicate could enqueue or store more work while processing | Verified for sequential duplicate | The test asserts one first-job/one first-payload and no second-request side effects | +| True simultaneous duplicate race | Open | Add two independent sessions/connections synchronized immediately inside the submission lock; assert one 201, one 403, one job and one payload | +| Rejected input could leave task-owned staging data | Verified before staging | Exact task-owned temporary, `new` and `in_process` paths remain absent after MIME rejection | +| Failure after staging or abandoned worker could leave data | Open | Inject a controlled failure after the first copy/move using isolated roots, define the cleanup contract, and assert owned paths are removed or recovered | +| Reviewed submission and comment-attachment logs expose content or student identifiers | Verified | Tests require exact 403/201 outcomes, safe markers, and absence of content, email, username and client filename; authentication and comment audit logging now use `user_id` | +| Repeated completed uploads could exhaust aggregate storage | Open | Define and test a per-user/unit quota or rate-limit policy; current evidence covers per-archive limits only | +| Comment attachment survives deletion | Verified | Direct model deletion, API deletion and subsequent 404 are covered | + +## Integration recommendation + +Merge the test and logging-sanitization changes after the exact security test +file and normal required CI checks pass. The evidence supports the verified +rows above. It does **not** support closing FILE-S01 as though true concurrent +races, post-staging cleanup and aggregate storage exhaustion were tested. + +Track the three open findings explicitly in the security objective. If the +ticket's acceptance criteria require every one of them before closure, keep the +ticket in progress even after this pull request merges. diff --git a/docs/security/FILE-S01-threat-model.md b/docs/security/FILE-S01-threat-model.md new file mode 100644 index 0000000000..ed04a4c851 --- /dev/null +++ b/docs/security/FILE-S01-threat-model.md @@ -0,0 +1,51 @@ +# FILE-S01 upload security threat model + +Date: 27 August 2026 + +## Scope + +This review covers the task-submission and task-comment attachment paths that +accept student-controlled files. The protected assets are another student's +work, task state and submission history, worker capacity, storage capacity, +server-side file paths, and identifiers or submitted content that could enter +logs. + +The relevant trust boundaries are: + +1. an unauthenticated client entering the authenticated API; +2. an authenticated student crossing into another project; +3. multipart metadata crossing into server-side MIME, extension and archive + validation; +4. a validated temporary upload crossing into task-owned staging storage and a + background job; and +5. request and validation data crossing into application logs. + +## Threats and verified controls + +| Threat | Expected control | Automated evidence | +| --- | --- | --- | +| Raw API submission without a session | Authentication rejects before task or storage work | `unauthenticated direct API upload is rejected with 419` | +| Cross-project POST, download or history access | Exact endpoint contract rejects the request; POST creates no task, submission, job or file | Three `student cannot ... another student` tests | +| Misleading extension, MIME or signature | Server-side allow-list and libmagic/PDF/archive validation reject the payload | MIME, signature, malformed-file and unsupported-extension tests | +| Archive traversal or resource amplification | Normalized archive paths plus entry-count, compression-ratio and uncompressed-size limits | ZIP traversal and resource-limit tests | +| Unsafe filename | Server-side path and filename sanitization | traversal, control-character and Unicode tests | +| Duplicate submission while work is queued | Task lock and queued-directory state reject the later request without additional state, payload or job | `sequential duplicate upload is blocked while first submission is queued` | +| Rejected input creates owned staging artifacts | Validation runs before state transition and staging | `early MIME rejection creates no task-owned staging artifacts` | +| Submission or attachment data leaks through reviewed log paths | Safe markers use internal ids and omit content, email, username and client filenames | three log-privacy tests | +| Deleted comment attachment remains retrievable | Model/API deletion removes the owned file and subsequent lookup returns 404 | comment attachment retention tests | + +## Deliberate limits + +The suite does not claim to prove all FILE-S01 risks are closed. In particular: + +- The duplicate test is sequential. It does not synchronize two independent + database connections at the row lock and therefore is not a true race test. +- Cleanup is proven only for rejection before staging. A controlled copy/move + failure after staging and an abandoned worker are not injected by this suite. +- Archive controls bound individual uploads, but the suite does not prove a + per-user storage quota or request-rate limit across many completed uploads. +- Log assertions cover submission validation and task-comment attachments, not + every historic file-related endpoint in the application. + +These limits are recorded as follow-ups in `FILE-S01-security-findings.md` and +must not be represented as passing evidence. diff --git a/docs/submission-lifecycle/effective-resubmission-deadline.md b/docs/submission-lifecycle/effective-resubmission-deadline.md new file mode 100644 index 0000000000..6e23185106 --- /dev/null +++ b/docs/submission-lifecycle/effective-resubmission-deadline.md @@ -0,0 +1,229 @@ +# The effective resubmission deadline + +**Status: the rule written here is the rule OnTrack already ran, written down. It is +not approved policy. SLR-E01 (Confirm the Intended Post-Feedback Deadline Rule) has +to confirm or correct it.** + +When staff send a task back to a student for more work, the student needs time to do +that work. If the deadline is close, OnTrack quietly moves it. Nobody had written down +what "close" means or how much time gets added, so SLR-E02 wrote it down and fixed the +parts that were wrong no matter which policy SLR-E01 lands on. + +## The rule as it stands + +A task earns one resubmission extension when all of these are true. + +| Condition | Where it lives | +|---|---| +| The task was set to Fix and Resubmit, Discuss, Rediscuss or Demonstrate | `Task#resubmission_extension_statuses` | +| The deadline is less than 7 days away, measured from the assessment | `Task#resubmission_extension_window` | +| The unit grants more than 0 weeks on resubmit | `Task#resubmission_extension_weeks` | +| The task can still be extended without passing the unit deadline | `Task#can_apply_for_extension?` | +| This round of feedback has not already had one | `Task#resubmission_extension_comment` | + +Two supporting values decide *when* those conditions are read. + +| Value | Where it lives | +|---|---| +| The moment the deadline passes, end of its day anywhere on earth | `Task#effective_deadline` | +| The far edge of the window, 7 days after the assessment | `Task#resubmission_extension_window_end` | + +The extension is the unit's `extension_weeks_on_resubmit_request`, capped so it never +runs past the unit deadline. Units that let students manage their own dates +(`allow_flexible_dates`) never get one. + +A round of feedback starts when the student submits. So a student who resubmits and is +sent back again earns another extension, and staff who assess the same submission twice +do not move the deadline twice. + +## What SLR-E01 has to decide + +1. Is 7 days the right window, and should it be measured from the assessment or from + the student reading the feedback. +2. Are those four statuses the right list. Demonstrate and Discuss ask the student to + turn up, not to resubmit, so they may not belong. +3. Is one extension per submission right, or should it be one per task for the whole + trimester. +4. Whether anything should be applied retroactively. Nothing here is. Tasks that were + over-extended by the old behaviour keep the weeks they were given. +5. Whose day a deadline belongs to. This branch says the student's, read off their campus, + because a deadline day that is not the student's day is not a deadline anyone can act + on. On an install that has left `campuses.timezone` empty nothing changes at all. On one + that has filled it in, a task near midnight can now fall on a different day than it did, + which means a small number of students qualify who did not, and the other way round. + +Changing 1, 2, 3 or 5 is a change to one of the methods named in the tables above. + +**Card requirement 1 is still open.** It asks the rule to conform to the approved policy, +and there is no approved policy: SLR-E01 has not started. Writing one here would be +inventing it. What this branch does instead is write down the rule OnTrack already ran and +put each part of it in one named place, so that confirming or correcting it later is a +small edit rather than an archaeology exercise. + +## Worked examples, all covered by tests in `test/models/task_test.rb` + +| Case | Result | +|---|---| +| Task due in 2 days, set to Fix and Resubmit | 1 week added, deadline moves once | +| Same task assessed again, same submission | Nothing changes | +| Same task set to Discuss straight after | Nothing changes | +| Student resubmits a week later, sent back again | A second week added | +| Tutor grants 2 more weeks, then reassesses | Stays at 3 weeks, nothing added or removed | +| Task due in 4 weeks, set to Fix and Resubmit | No extension | +| Unit grants 0 weeks on resubmit | No extension | +| Assessment processed with a date from 3 weeks ago | No extension, the window was shut then | +| A prerequisite fix cascades to a dependent task, twice | The dependent task gets 1 week, not 2 | +| Melbourne task due 10:30, either side of a clock change | Due at the end of the day it was set for, both times | +| Seven days from 09:00, the week the clocks move | Ends at 09:00, 167 real hours one way and 169 the other | +| Due Mon 5 Oct 2026, sent back Thu 1 Oct, clocks forward on the Sunday | Due Mon 12 Oct, not Sun the 11th | +| A student asks for a week, then is sent back near the deadline | Two separate extensions, only the second is a resubmission one | + +## Why the deadline used to move more than once + +`Task#grant_extension` adds weeks, it does not set them. The old code ran the whole check +on every call to `Task#assess`, so a second Fix and Resubmit on the same submission added +another week, and so did the recursive fix that cascades to dependent tasks. An already +overdue task was the worst case, because it stays inside the 7 day window after being +extended, so it could be extended again and again. + +## What the fix does + +- One extension per round of feedback. The check is an `ExtensionComment` recorded against + the task, so it survives restarts, retries and duplicate events, and needed no migration. +- That comment is also the audit trail. It records the weeks, the status that triggered it, + who assessed it, when, and a sentence the student can read. `task_status_id` is set on the + ones OnTrack worked out and nil on ones a student asked for, which is what tells them + apart. `ExtensionComment#serialize` exposes `resubmission_extension` and `source_status` + for the interface and for notifications. +- **Not `automatic`.** That word was already taken. `ExtensionComment#assess_extension` uses + it for a request a student made that the unit approved without a person weighing it up, + which is a different thing entirely - it is about who signed the extension off, not about + where it came from. One word carrying two meanings inside one class is how the wrong + branch gets taken, so the predicate is `resubmission_extension?` and the parameter on + `assess_extension` is `auto_approved`. Nothing in the class says "automatic" any more. +- The window is measured from the assessment's own timestamp rather than the wall clock, + so replaying an event gives the answer it gave at the time, and the seven days are added + as a duration rather than 168 fixed hours. +- The whole calculation is now done in the student's own time zone, which is the fix for + the date drift described in the next section. + +## Which day a deadline falls on + +This is the part that was wrong, and it was wrong in two ways at once. + +A deadline in OnTrack is a day, not an instant. A task due on Monday is not late until +Monday is over, and OnTrack is generous about that: it treats the deadline as the end of +that day *anywhere on earth*, which is 23:59:59 at UTC-12. So the one thing the code has +to get right is which day it is talking about. + +It got that day by reading the year, month and day straight off the deadline as the +database handed it back. That reads them in whatever `Time.zone` is, and **nothing in +`config/` sets `config.time_zone`, so `Time.zone` is UTC**. Meanwhile every campus carries +its own `timezone` column, added in `20251016033638_add_timezone_to_campuses`, and nothing +in this calculation read it. + +That is not just an offset. A campus in Melbourne is +11:00 through summer and +10:00 +through winter, so the same wall clock deadline sits on one UTC day for half the year and +the next one for the other half. A task due at 10:30 on Thursday 2 April 2026 was treated +as due on Wednesday the 1st. The identical task a week later, on Thursday 9 April, was +treated as due on Thursday the 9th, because the clocks had gone back on the Sunday in +between. Two deadlines set a week apart came out eight days apart. The seven day window +had the matching problem in the other direction, landing an hour late in the week the +clocks go forward and an hour early in the week they go back. + +The fix is that the calculation now names its own zone instead of inheriting one. + +| Method | What it does now | +|---|---| +| `Task#deadline_time_zone` | The campus's `timezone`, falling back to the application zone | +| `Task#deadline_date` | Reads a deadline's calendar day in that zone | +| `Task#to_same_day_anywhere_on_earth` | Builds the end of that day at a fixed `-12:00` | +| `Task#resubmission_extension_window_end` | Adds seven days in that zone, so it keeps its wall clock | + +`Campus#timezone` already falls back to the application zone when the column is empty, and +a project with no campus falls back to the same place. **So on an install that has not +filled in campus time zones, every one of these produces exactly the value it produced +before.** On an install that has filled them in, the deadline is now the student's day. + +`Task#raw_extension_date` and `Task#max_date_with_spec_con_days` were fixed at the same +time and for the same reason. They are what turn extension weeks into a date, so leaving +them reading the day in UTC would have put the corrected deadline back onto the wrong day +as soon as a task was extended. + +Three tests in `test/models/task_test.rb` cover this, all of them on a real Australian +campus and none of them touching the application zone. Reverted against the old +calculation they fail by a day on the deadline and by an hour on the window. + +**`config.time_zone` is deliberately still unset.** Setting it is a one line change with a +blast radius across every date in the product, and this branch targets `11.0.x`, which is a +release branch. It is named as a follow-up below rather than done here. + +## Known gaps, not fixed here + +Group submissions copy the submitter's extension count onto each member task and then run +the check on each of them, so a group can end up further ahead than its submitter. That is +a separate defect in `GroupSubmission#propagate_transition` and it needs its own ticket. + +These came out of an independent review of the change. None of them is a regression, every +one of them is either older than this branch or a consequence of deliberately not making a +retroactive change, and each needs a decision from SLR-E01 rather than a quiet fix. + +### SLR-E02-F1: set `config.time_zone`, or decide not to + +Nothing in `config/` sets `config.time_zone`, so the application zone is UTC everywhere. +The deadline calculation no longer cares, because it names the campus zone itself. It is +the only thing in the product that does. + +That is the follow-up. Every other date OnTrack renders, sorts, groups or writes to a +webcal is still read in UTC, including on a Melbourne campus that is ten or eleven hours +ahead of it, and a fair number of those will be a day out on the screen for exactly the +reason the deadline was. +Setting `config.time_zone` is one line, and one line with a blast radius across the whole +product, so it does not belong on `11.0.x` next to a deadline fix. **It is not done here on +purpose.** It needs its own ticket, its own read of what breaks, and a call on whether a +single application zone is even the right answer for a product with campuses on different +ones. + +### SLR-E02-F2: tasks extended by the old code carry no marker + +The guard asks whether this round of feedback already has an `ExtensionComment` recording a +resubmission extension. The old code created none, so a task that was already extended by +the old behaviour looks untouched. The first time the same submission is assessed after this +lands, it can be extended one more time. From then on it is idempotent like everything else. + +So the exposure is **one extra week, once, per affected task** - and only where the task was +already extended by the old code, is reassessed before the student submits again, and is +still inside the seven day window. The unbounded case, where an overdue task could be +extended on every single pass, is closed by this branch regardless. + +Three ways to close the rest were considered and none of them is safe to do here. + +| Option | Why not | +|---|---| +| Backfill comments for the old extensions | Nobody recorded which extensions were automatic or what triggered them, so this writes an audit trail that was never true, into every affected student's comment thread | +| Treat "extension weeks no comment accounts for" as already spent | `GroupSubmission#propagate_transition` copies the submitter's extension count onto every member task without a comment, so this would silently deny group members their first legitimate extension | +| Stamp a one-off marker in a migration | Needs a `task_status_id` the migration cannot know, and a student-visible comment on every affected task | + +**So this is a data migration decision, not a code one, and it needs the retroactivity call +from SLR-E01 first.** Requirement 6 of the card rules out retroactive changes, and every +option above is one. Named here so it is picked up deliberately rather than discovered. + +**Nothing serialises the check.** The read of the guard, the `extensions` update and the +comment insert are three statements with no lock and no transaction around them. Two +assessments landing together can both see no comment and both grant. A row lock on the task +would close it and is the obvious follow-up. + +**The extension is written before the comment.** `grant_extension` persists first and +`record_resubmission_extension` saves after. If the comment raises, the deadline has already +moved and no key exists to stop the next assessment moving it again. Wrapping the pair in a +transaction is the fix and it belongs with the lock above. + +**Group threads show one comment per member.** The comment is recorded against each member +task, and `Task#all_comments` returns every comment across the group submission, so a +three-person group assessed once shows three resubmission extension comments to everyone. The +extensions themselves are per task and correct. Only the thread is noisy. + +**Second-precision timestamps.** The guard compares `date_extension_assessed` against +`submission_date`. On a database still using the older second-precision `datetime` columns, +an assessment and a genuine resubmission inside the same second compare equal and the new +round is suppressed. Unlikely by hand, reachable by a script. 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/demo_data/all_features_scenario.rb b/lib/demo_data/all_features_scenario.rb new file mode 100644 index 0000000000..eea3211f74 --- /dev/null +++ b/lib/demo_data/all_features_scenario.rb @@ -0,0 +1,595 @@ +# frozen_string_literal: true + +module DemoData + # Builds the small, deterministic API dataset used by the all-features demo. + # + # This is intentionally not a general seed. Both creation and cleanup refuse + # to run unless all three safety conditions match the dedicated local demo + # database. Re-running creation first removes this namespace and rebuilds it, + # so partial runs and stale relative dates cannot accumulate duplicate data. + class AllFeaturesScenario + class SafetyError < StandardError; end + + DATABASE_NAME = 'doubtfire-all-features-demo' + PROFILE_NAME = 'all-features' + CAMPUS_NAME = 'All Features Demo Campus' + CAMPUS_ABBREVIATION = 'AFDEMO' + DEMO_USERNAME = 'demo_student' + CONVENOR_USERNAME = 'demo_convenor' + PEER_USERNAMES = (1..24).map do |number| + "demo_peer_#{number.to_s.rjust(2, '0')}" + end.freeze + USERNAMES = [DEMO_USERNAME, CONVENOR_USERNAME, *PEER_USERNAMES].freeze + CURRENT_UNIT_CODES = %w[ + DEMO10001 + DEMO20007 + DEMO30046 + DEMO30243 + ].freeze + PREVIOUS_UNIT_CODE = 'DEMO09999' + UNIT_CODES = [*CURRENT_UNIT_CODES, PREVIOUS_UNIT_CODE].freeze + PPI_UNIT_CODE = 'DEMO10001' + PPI_TASK_ABBREVIATION = 'DUE7' + COHORT_SIZE = 25 + PPI_STATUS_COUNTS = { + not_started: 5, + working_on_it: 5, + ready_for_feedback: 4, + fix_and_resubmit: 3, + redo: 3, + complete: 3, + fail: 2 + }.freeze + PPI_REQUIRED_VISIBLE_STATUSES = PPI_STATUS_COUNTS.keys.freeze + PPI_UPLOADED_STATUSES = %i[ + ready_for_feedback + fix_and_resubmit + redo + complete + fail + ].freeze + PPI_PEER_STATUS_KEYS = PPI_STATUS_COUNTS.flat_map do |status, count| + peer_count = status == :not_started ? count - 1 : count + [status] * peer_count + end.freeze + SUBMITTED_COUNT = PPI_UPLOADED_STATUSES.sum do |status| + PPI_STATUS_COUNTS.fetch(status) + end + NOTIFICATION_COUNT = 7 + + TASK_BLUEPRINTS = [ + { + abbreviation: 'OVERDUE', + name: 'Overdue Foundations', + start_offset: -21, + target_offset: -1, + status: :not_started, + weighting: 3 + }, + { + abbreviation: 'DUE3', + name: 'Due Within Three Days', + start_offset: -10, + # The web maps date-only deadlines to the end of that day. Two calendar + # days ahead therefore stays inside the 72-hour warning window all day. + target_offset: 2, + status: :not_started, + weighting: 6 + }, + { + abbreviation: PPI_TASK_ABBREVIATION, + name: 'Due Within Seven Days', + start_offset: -7, + # Likewise, six days ahead remains inside the seven-day warning window + # after the client applies its end-of-day display convention. + target_offset: 6, + status: :not_started, + weighting: 4 + }, + { + abbreviation: 'FUTURE', + name: 'Future Planning', + start_offset: 10, + target_offset: 14, + status: :not_started, + weighting: 2 + }, + { + abbreviation: 'WORK', + name: 'Work in Progress', + start_offset: -5, + target_offset: 10, + status: :working_on_it, + weighting: 5 + }, + { + abbreviation: 'DONE', + name: 'Completed Practice', + start_offset: -28, + target_offset: -7, + status: :complete, + weighting: 1 + } + ].freeze + + UNIT_NAMES = { + 'DEMO10001' => 'Foundations of OnTrack', + 'DEMO20007' => 'Active Learning Studio', + 'DEMO30046' => 'Applied Project Delivery', + 'DEMO30243' => 'Professional Practice', + PREVIOUS_UNIT_CODE => 'Previous Study Portfolio' + }.freeze + + def self.run!(reference_time: Time.zone.now) + new(reference_time: reference_time).run! + end + + def self.cleanup! + new(reference_time: Time.zone.now).cleanup! + end + + def self.verify!(reference_time: Time.zone.now) + new(reference_time: reference_time).verify! + end + + def initialize(reference_time:) + @reference_time = reference_time.in_time_zone.beginning_of_day + end + + def run! + guard! + + result = nil + ActiveRecord::Base.transaction do + cleanup_records! + create_scenario! + result = summary + end + result + end + + def cleanup! + guard! + + ActiveRecord::Base.transaction { cleanup_records! } + true + end + + def verify! + guard! + + minimum_cohort_size = configured_positive_integer!( + 'DF_PPI_MINIMUM_COHORT_SIZE' + ) + stale_after_hours = configured_positive_integer!( + 'DF_PPI_STALE_AFTER_HOURS' + ) + if minimum_cohort_size < PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + raise SafetyError, + 'DF_PPI_MINIMUM_COHORT_SIZE is below the API privacy floor.' + end + + student = User.find_by!(username: DEMO_USERNAME) + unit = Unit.find_by!(code: PPI_UNIT_CODE) + definition = unit.task_definitions.find_by!( + abbreviation: PPI_TASK_ABBREVIATION + ) + project = student.projects.find_by!(unit: unit) + viewer_task = project.tasks.find_by!(task_definition: definition) + snapshot = unit.peer_progress_snapshots.find_by!( + task_definition: definition, + target_grade: project.target_grade + ) + + cohort_size = unit.active_projects.where( + target_grade: project.target_grade + ).count + unless unit.active? && unit.peer_progress_enabled? && + student.display_peer_progress? && project.enrolled? && + cohort_size == COHORT_SIZE && + cohort_size - 1 >= minimum_cohort_size + raise SafetyError, + 'All-features peer-progress cohort or display settings are invalid.' + end + + unless definition.target_grade <= project.target_grade && + definition.start_date.present? && + definition.start_date <= Time.zone.now + raise SafetyError, + 'All-features peer-progress task is not released for the demo student.' + end + + latest_grade_change = unit.active_projects.where( + target_grade: project.target_grade + ).maximum(:target_grade_changed_at) + unless snapshot.cohort_size == cohort_size && + snapshot.submitted_count.is_a?(Integer) && + snapshot.submitted_percentage.present? && + snapshot.calculated_at >= stale_after_hours.hours.ago && + (latest_grade_change.nil? || + snapshot.calculated_at >= latest_grade_change) + raise SafetyError, + 'All-features peer-progress snapshot is stale or inconsistent.' + end + + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + if peer_progress.nil? + raise SafetyError, + 'All-features peer-progress snapshot cannot exclude the demo viewer.' + end + + public_metrics = PeerProgressViewerPolicy.public_metrics(peer_progress) + distribution = public_metrics.fetch(:status_distribution) + unless distribution&.length == + PeerProgressDistributionPolicy::STATUS_KEYS.length + raise SafetyError, + 'All-features detailed peer-progress distribution is suppressed.' + end + + percentages = distribution.index_by do |entry| + entry.fetch(:status).to_sym + end + unless PPI_REQUIRED_VISIBLE_STATUSES.all? do |status| + percentages.fetch(status).fetch(:percentage).positive? + end + raise SafetyError, + 'All-features peer-progress lifecycle statuses are not visible.' + end + + { + profile: PROFILE_NAME, + submitted_percentage: public_metrics.fetch(:submitted_percentage), + completed_percentage: public_metrics.fetch(:completed_percentage), + status_distribution: distribution + } + rescue ActiveRecord::RecordNotFound => e + raise SafetyError, + "All-features demo data is incomplete: #{e.message}" + end + + def guard! + unless Rails.env.development? + raise SafetyError, + 'All-features demo data can run only in Rails development.' + end + + database_name = connected_database_name + unless database_name == DATABASE_NAME + raise SafetyError, + "All-features demo data requires database #{DATABASE_NAME.inspect}; " \ + "connected to #{database_name.inspect}." + end + + return if ENV.fetch('DF_DEMO_DATA_PROFILE', nil) == PROFILE_NAME + + raise SafetyError, + 'Set DF_DEMO_DATA_PROFILE=all-features to confirm this demo-only operation.' + end + + private + + attr_reader :reference_time + + def connected_database_name + ActiveRecord::Base.connection_db_config.database.to_s + end + + def configured_positive_integer!(name) + value = Integer(ENV.fetch(name), 10) + raise ArgumentError unless value.positive? + + value + rescue KeyError, ArgumentError + raise SafetyError, "#{name} must be a positive integer." + end + + def create_scenario! + ensure_reference_data! + campus = create_campus! + convenor = create_user!( + username: CONVENOR_USERNAME, + first_name: 'Demo', + last_name: 'Convenor', + role: Role.convenor + ) + demo_student = create_user!( + username: DEMO_USERNAME, + first_name: 'Demo', + last_name: 'Student', + role: Role.student, + student_id: 'DEMO-STUDENT' + ) + + units = UNIT_CODES.index_with do |code| + create_unit!(code: code, convenor: convenor) + end + + units.each_value do |unit| + project = enrol!(unit: unit, student: demo_student, campus: campus) + materialise_demo_tasks!(project) + end + + create_ppi_cohort!(unit: units.fetch(PPI_UNIT_CODE), campus: campus) + aggregate_peer_progress!(units.fetch(PPI_UNIT_CODE)) + create_notifications!(demo_student) + end + + def ensure_reference_data! + missing_roles = (1..Role.auditor_id).reject { |id| Role.exists?(id: id) } + missing_statuses = (1..TaskStatus.count).reject do |id| + TaskStatus.exists?(id: id) + end + return if missing_roles.empty? && missing_statuses.empty? + + raise SafetyError, + 'Run db:init before db:all_features_demo; required roles or task statuses are missing.' + end + + def create_campus! + Campus.create!( + name: CAMPUS_NAME, + abbreviation: CAMPUS_ABBREVIATION, + mode: :manual, + active: true, + timezone: 'Australia/Melbourne' + ) + end + + def create_user!( + username:, + first_name:, + last_name:, + role:, + student_id: nil, + notifications_enabled: true + ) + User.create!( + username: username, + login_id: username, + email: "#{username}@all-features.invalid", + first_name: first_name, + last_name: last_name, + nickname: first_name, + role: role, + student_id: student_id, + password: 'password', + password_confirmation: 'password', + receive_task_notifications: notifications_enabled, + receive_feedback_notifications: notifications_enabled, + receive_portfolio_notifications: notifications_enabled, + display_peer_progress: true, + opt_in_to_research: false, + has_run_first_time_setup: true + ) + end + + def create_unit!(code:, convenor:) + previous = code == PREVIOUS_UNIT_CODE + unit = Unit.create!( + code: code, + name: UNIT_NAMES.fetch(code), + description: 'Synthetic local data for the isolated all-features demo.', + start_date: previous ? reference_time - 24.weeks : reference_time - 6.weeks, + end_date: previous ? reference_time - 8.weeks : reference_time + 7.weeks, + active: !previous, + send_notifications: false, + enable_sync_timetable: false, + enable_sync_enrolments: false, + allow_flexible_dates: false, + peer_progress_enabled: code == PPI_UNIT_CODE, + grade_definitions: Unit::DEFAULT_GRADE_DEFINITIONS + ) + unit.employ_staff(convenor, Role.convenor) + create_task_definitions!(unit) + unit + end + + def create_task_definitions!(unit) + TASK_BLUEPRINTS.each do |blueprint| + TaskDefinition.create!( + unit: unit, + name: blueprint.fetch(:name), + abbreviation: blueprint.fetch(:abbreviation), + description: 'Synthetic task for the isolated all-features demo.', + weighting: blueprint.fetch(:weighting), + target_grade: 0, + start_date: reference_time + blueprint.fetch(:start_offset).days, + target_date: reference_time + blueprint.fetch(:target_offset).days, + due_date: reference_time + (blueprint.fetch(:target_offset) + 4).days, + upload_requirements: [ + { + 'key' => 'file0', + 'name' => 'Demo document', + 'type' => 'document' + } + ] + ) + end + end + + def enrol!(unit:, student:, campus:) + project = unit.enrol_student(student, campus) + project.update!( + target_grade: 0, + enrolled: true, + started: true, + progress: 'Synthetic all-features demo progress.' + ) + project + end + + def materialise_demo_tasks!(project) + TASK_BLUEPRINTS.each do |blueprint| + status = TaskStatus.public_send(blueprint.fetch(:status)) + attributes = { + project: project, + task_definition: project.unit.task_definitions.find_by!( + abbreviation: blueprint.fetch(:abbreviation) + ), + task_status: status + } + + if status == TaskStatus.complete + attributes[:completion_date] = (reference_time - 8.days).to_date + attributes[:submission_date] = reference_time - 9.days + end + + Task.create!(attributes) + end + project.update_task_stats + end + + def create_ppi_cohort!(unit:, campus:) + ppi_definition = unit.task_definitions.find_by!( + abbreviation: PPI_TASK_ABBREVIATION + ) + + PEER_USERNAMES.each_with_index do |username, index| + student = create_user!( + username: username, + first_name: 'Demo', + last_name: "Peer #{(index + 1).to_s.rjust(2, '0')}", + role: Role.student, + student_id: "DEMO-PEER-#{(index + 1).to_s.rjust(2, '0')}", + notifications_enabled: false + ) + project = enrol!(unit: unit, student: student, campus: campus) + status_key = PPI_PEER_STATUS_KEYS.fetch(index) + status = TaskStatus.public_send(status_key) + uploaded = PPI_UPLOADED_STATUSES.include?(status_key) + submitted_at = uploaded ? reference_time - 1.day : nil + Task.create!( + project: project, + task_definition: ppi_definition, + task_status: status, + file_uploaded_at: submitted_at, + submission_date: submitted_at, + completion_date: + status_key == :complete ? (reference_time - 1.day).to_date : nil + ) + project.update_task_stats + end + end + + def aggregate_peer_progress!(unit) + # Run the production aggregation job synchronously. Calling #perform does + # not enqueue Sidekiq work and therefore does not touch the running demo. + AggregatePeerProgressJob.new.perform(unit.id) + end + + def create_notifications!(student) + projects_by_code = student.projects.includes(:unit).index_by do |project| + project.unit.code + end + project = projects_by_code.fetch(PPI_UNIT_CODE) + task_notifications = CURRENT_UNIT_CODES.each_with_index.map do |code, index| + { + type: 'task', + event: 'task_due_soon', + message: "DUE3 in #{code} is due soon.", + link: "/projects/#{projects_by_code.fetch(code).id}/dashboard/DUE3", + dedupe_suffix: "task_due_soon:#{code}", + age: (15 + (index * 10)).minutes, + read: false + } + end + notification_blueprints = [ + *task_notifications, + { + type: 'feedback', + event: 'demo_feedback_ready', + message: 'New feedback is ready for WORK in DEMO10001.', + link: "/projects/#{project.id}/dashboard/WORK/feedback", + age: 2.hours, + read: false + }, + { + type: 'portfolio', + event: 'demo_portfolio_available', + message: 'Your DEMO10001 portfolio is ready to review.', + link: "/projects/#{project.id}/dashboard", + age: 1.day, + read: true + }, + { + type: 'general', + event: 'demo_welcome', + message: 'Welcome to the isolated all-features demo.', + link: "/projects/#{project.id}/dashboard/OVERDUE", + age: 2.days, + read: true + } + ] + + notification_blueprints.each do |blueprint| + created_at = reference_time - blueprint.fetch(:age) + notification = NotificationService.reserve( + user: student, + type: blueprint.fetch(:type), + event: blueprint.fetch(:event), + message: blueprint.fetch(:message), + link: blueprint.fetch(:link), + dedupe_key: "all_features_demo:#{blueprint.fetch(:dedupe_suffix, blueprint.fetch(:event))}" + ) + notification.update!( + created_at: created_at, + updated_at: created_at, + delivered_at: created_at, + read_at: blueprint.fetch(:read) ? created_at + 5.minutes : nil + ) + end + end + + def cleanup_records! + Unit.where(code: UNIT_CODES).find_each(&:destroy!) + User.where(username: USERNAMES).find_each(&:destroy!) + Campus.find_by(abbreviation: CAMPUS_ABBREVIATION)&.destroy! + end + + def summary + ppi_unit = Unit.find_by!(code: PPI_UNIT_CODE) + ppi_definition = ppi_unit.task_definitions.find_by!( + abbreviation: PPI_TASK_ABBREVIATION + ) + snapshot = ppi_unit.peer_progress_snapshots.find_by!( + task_definition: ppi_definition, + target_grade: 0 + ) + demo_project = User.find_by!(username: DEMO_USERNAME) + .projects.find_by!(unit: ppi_unit) + viewer_task = demo_project.tasks.find_by!( + task_definition: ppi_definition + ) + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: demo_project, + viewer_task: viewer_task + ) + public_metrics = PeerProgressViewerPolicy.public_metrics(peer_progress) + + { + profile: PROFILE_NAME, + login: DEMO_USERNAME, + password: 'password', + unit_codes: UNIT_CODES, + users: User.where(username: USERNAMES).count, + projects: Project.joins(:unit).where(units: { code: UNIT_CODES }).count, + tasks: Task.joins(project: :unit).where(units: { code: UNIT_CODES }).count, + notifications: User.find_by!(username: DEMO_USERNAME).notifications.count, + push_subscriptions: PushSubscription.joins(:user).where(users: { username: USERNAMES }).count, + peer_progress: { + unit_code: PPI_UNIT_CODE, + task_abbreviation: PPI_TASK_ABBREVIATION, + submitted_percentage: public_metrics.fetch(:submitted_percentage), + completed_percentage: public_metrics.fetch(:completed_percentage), + distribution_available: + public_metrics.fetch(:status_distribution).present? + } + } + end + end +end diff --git a/lib/helpers/database_populator.rb b/lib/helpers/database_populator.rb index b415a30ec2..589e52d3a7 100644 --- a/lib/helpers/database_populator.rb +++ b/lib/helpers/database_populator.rb @@ -175,8 +175,12 @@ def generate_overseer_images tag: 'bash:latest' ) - echo_line "---> Pulling overseer image #{overseer_image.tag}" - overseer_image.pull_from_docker + if ENV['SKIP_OVERSEER_IMAGE_PULL_ON_POPULATE'] == 'true' + echo_line "---> Skipping overseer image pull for #{overseer_image.tag}" + else + echo_line "---> Pulling overseer image #{overseer_image.tag}" + overseer_image.pull_from_docker + end end # @@ -211,10 +215,9 @@ def generate_users(filter = nil) if AuthenticationHelpers.aaf_auth? user = User.create!(profile) else - user = User.create!(profile.merge({ - password: 'password', - password_confirmation: 'password' - })) + user = User.new(profile) + user.password = 'password' + user.save! end @user_cache[user_key] = user @@ -651,7 +654,7 @@ def generate_tasks_for_unit(unit, unit_details) if (File.exist? csv_to_import) && (File.exist? zip_to_import) echo "----> CSV file found, importing tasks from #{csv_to_import} \n" - result = unit.import_tasks_from_csv(File.open(csv_to_import)) + result = unit.import_tasks_from_csv(File.open(csv_to_import), notify: false) unless result[:errors].empty? raise("----> Task import from CSV failed with the following errors: #{result[:errors]} \n") end diff --git a/lib/helpers/find_or_create_students.rb b/lib/helpers/find_or_create_students.rb index 19995782a3..e9a583caae 100644 --- a/lib/helpers/find_or_create_students.rb +++ b/lib/helpers/find_or_create_students.rb @@ -13,11 +13,9 @@ def find_or_create_student(username) email: "#{username}@doubtfire.com", username: username } - unless AuthenticationHelpers.aaf_auth? - profile[:password] = 'password' - profile[:password_confirmation] = 'password' - end - user_created = User.create!(profile) + user_created = User.new(profile) + user_created.password = 'password' unless AuthenticationHelpers.aaf_auth? + user_created.save! @user_cache[username] = user_created if using_cache else user_created = User.find_by(username: username) 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/shell/sidekiq_entry_point.sh b/lib/shell/sidekiq_entry_point.sh index 7a44f7bd50..d32595094e 100755 --- a/lib/shell/sidekiq_entry_point.sh +++ b/lib/shell/sidekiq_entry_point.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -euo pipefail + # Start the run once job. echo "Sidekiq docker container has been started" @@ -9,14 +11,16 @@ newaliases # 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 mail settings are accessible only by root -chown root:root /etc/msmtprc -chmod 600 /etc/msmtprc +# Ensure existing mail settings are accessible only by root. +if [ -f /etc/msmtprc ]; then + chown root:root /etc/msmtprc + chmod 0600 /etc/msmtprc +fi -# Run sidekiq -bundle exec sidekiq +# Make Sidekiq PID 1 so Docker stop signals reach it directly. +exec bundle exec sidekiq diff --git a/lib/shell/write_cron_environment.sh b/lib/shell/write_cron_environment.sh new file mode 100755 index 0000000000..4bc0b194af --- /dev/null +++ b/lib/shell/write_cron_environment.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +set -euo pipefail + +# Cron starts jobs with a deliberately small environment. Persist only the +# application and Ruby runtime settings that those jobs can need, rather than +# copying every variable inherited by the container. Values are shell-escaped +# because this file is sourced through BASH_ENV by .ci-setup/crontab. +is_cron_environment_variable() { + case "$1" in + BUNDLE_* | DATABASE_URL | DF_* | D2L_* | DISK_SPACE_ENDPOINT_ENABLED | \ + DOCKER_CERT_PATH | DOCKER_HOST | DOCKER_PROXY_URL | DOCKER_REGISTRY_URL | \ + DOCKER_TLS_VERIFY | DOCKER_TOKEN | DOCKER_USER | DOUBTFIRE_* | GEM_HOME | \ + GEM_PATH | GOTENBERG_* | HTTP_PROXY | HTTPS_PROXY | LANG | LATEX_* | \ + LC_* | LTI_* | MODERATION_SCORE_FACTOR | NO_PROXY | OVERSEER_* | \ + RABBITMQ_* | RACK_ENV | RAILS_* | RUBYLIB | RUBYOPT | SENTRY_* | \ + SSL_CERT_DIR | SSL_CERT_FILE | TCA_* | TII_* | TMPDIR | TZ | \ + http_proxy | https_proxy | no_proxy) + return 0 + ;; + *) + return 1 + ;; + esac +} + +destination="${1:-/container.env}" +temporary_file='' + +cleanup() { + if [[ -n "${temporary_file}" ]]; then + rm -f -- "${temporary_file}" + fi +} + +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +umask 077 +temporary_file="$(mktemp "${destination}.tmp.XXXXXX")" + +while IFS= read -r variable_name; do + if is_cron_environment_variable "${variable_name}"; then + printf 'export %s=%q\n' \ + "${variable_name}" "${!variable_name}" >> "${temporary_file}" + fi +done < <(compgen -e | LC_ALL=C sort) + +chmod 0600 "${temporary_file}" +mv -f -- "${temporary_file}" "${destination}" +temporary_file='' +trap - EXIT HUP INT TERM diff --git a/lib/tasks/all_features_demo.rake b/lib/tasks/all_features_demo.rake new file mode 100644 index 0000000000..59d46adf8f --- /dev/null +++ b/lib/tasks/all_features_demo.rake @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require Rails.root.join('lib/demo_data/all_features_scenario') + +namespace :db do + desc 'Recreate the guarded, local all-features demo dataset' + task all_features_demo: :environment do + Rails.logger.level = Logger::INFO + result = DemoData::AllFeaturesScenario.run! + + puts "All-features demo data is ready: #{result.inspect}" + end + + desc 'Verify the guarded all-features demo dataset without changing it' + task all_features_demo_verify: :environment do + result = DemoData::AllFeaturesScenario.verify! + + puts "All-features demo data passed verification: #{result.inspect}" + end + + desc 'Remove only the guarded all-features demo dataset' + task all_features_demo_cleanup: :environment do + Rails.logger.level = Logger::INFO + DemoData::AllFeaturesScenario.cleanup! + + puts 'All-features demo data has been removed.' + end +end diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake index d7053b6e2b..4404b62dfb 100644 --- a/lib/tasks/maintenance.rake +++ b/lib/tasks/maintenance.rake @@ -206,11 +206,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! diff --git a/lib/tasks/ppi_sample_data.rake b/lib/tasks/ppi_sample_data.rake new file mode 100644 index 0000000000..f1dd77cdcc --- /dev/null +++ b/lib/tasks/ppi_sample_data.rake @@ -0,0 +1,291 @@ +require_all 'lib/helpers' +require Rails.root.join('lib/demo_data/all_features_scenario') + +PPI_SAMPLE_LIFECYCLE_STATUSES = %i[ + not_started + working_on_it + ready_for_feedback + fix_and_resubmit + redo + complete + fail +].freeze +PPI_SAMPLE_UPLOADED_STATUSES = %i[ + ready_for_feedback + fix_and_resubmit + redo + complete + fail +].freeze + +def ppi_viewer_vectors_safe?(unit:, snapshot:, minimum_cohort_size:) + viewers = unit.active_projects.where( + target_grade: snapshot.target_grade + ) + + viewers.all? do |project| + viewer_task = project.tasks.find_by!( + task_definition_id: snapshot.task_definition_id + ) + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + peer_progress.present? && + peer_progress.fetch(:cohort_size) >= minimum_cohort_size && + PeerProgressViewerPolicy + .public_metrics(peer_progress) + .fetch(:status_distribution) + .present? + end +end + +namespace :db do + desc 'Create deterministic, privacy-threshold-ready demo data for the Peer Progress Indicator dashboard' + task ppi_sample_data: :environment do + # This task creates hundreds of synthetic users, enrolments and tasks. Use + # the same non-interactive triple guard as the all-features demo instead of + # permitting a typed confirmation against an arbitrary production database. + DemoData::AllFeaturesScenario.new(reference_time: Time.zone.now).guard! + + Rails.logger.level = :info + + # ---- configuration ------------------------------------------------- + num_units = 2 + classes_per_unit = 2 + legacy_students_per_grade = 4 + grade_labels = { 0 => 'Pass', 1 => 'Credit', 2 => 'Distinction', 3 => 'HighDistinction' }.freeze + grades = grade_labels.keys.freeze # [0, 1, 2, 3] + num_tasks = 7 # within the requested 5-10 range + weekdays = %w[Monday Tuesday Wednesday Thursday Friday].freeze + + # ---- helpers --------------------------------------------------------- + + def ppi_positive_integer_env!(name) + value = Integer(ENV.fetch(name), 10) + raise ArgumentError unless value.positive? + + value + rescue KeyError, ArgumentError + raise ArgumentError, "#{name} must be a positive integer" + end + + # Finds or creates a user with a fixed, deterministic username - safe to re-run. + def ppi_find_or_create_user(username, first_name, last_name, role_id) + existing = User.find_by(username: username) + if existing + existing.update!(role_id: role_id) if existing.role_id != role_id + return existing + end + + profile = { + first_name: first_name, + last_name: last_name, + nickname: username, + role_id: role_id, + email: "#{username}@doubtfire.com", + username: username + } + unless AuthenticationHelpers.aaf_auth? + profile[:password] = 'password' + profile[:password_confirmation] = 'password' + end + User.create!(profile) + end + + minimum_cohort_size = ppi_positive_integer_env!('DF_PPI_MINIMUM_COHORT_SIZE') + stale_after_hours = ppi_positive_integer_env!('DF_PPI_STALE_AFTER_HOURS') + if minimum_cohort_size < PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + raise ArgumentError, + "DF_PPI_MINIMUM_COHORT_SIZE must be at least #{PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE}" + end + + # The authenticated viewer is removed before the threshold is applied, so + # each exact-grade cohort needs at least one more student than the peer floor. + required_total_cohort = minimum_cohort_size + 1 + students_per_grade = required_total_cohort.fdiv(classes_per_unit).ceil + baseline_students_per_grade = + (PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + 1) + .fdiv(classes_per_unit).ceil + sample_start_date = Time.zone.now - 6.weeks + sample_end_date = Time.zone.now + 7.weeks + + campus = Campus.first || Campus.create!(name: 'Online', mode: 'timetable', abbreviation: 'C', active: true) + convenor = ppi_find_or_create_user('ppi_convenor', 'Peer', 'Convenor', Role.convenor_id) + + (1..num_units).each do |unit_num| + code = "PPI100#{unit_num}" + unit = Unit.find_or_initialize_by(code: code) + unit.update!( + name: "PPI Sample Unit #{unit_num}", + description: 'Deterministic sample data for testing the Peer Progress Indicator dashboard. Not a real unit.', + start_date: sample_start_date, + end_date: sample_end_date, + active: true, + send_notifications: false, + allow_flexible_dates: false, + peer_progress_enabled: true + ) + + unless grades.all? { |target_grade| unit.grade_value?(target_grade) } + raise "#{unit.code} must retain the four standard target grades for the PPI demo" + end + + unit.employ_staff(convenor, Role.convenor) + + # All tasks are assigned regardless of a student's target grade (target_grade: 0 = Pass), + # so every student in the unit has the same task list - needed to compare % completion + # meaningfully across target-grade bands. + task_defs = (1..num_tasks).map do |t| + task_definition = unit.task_definitions.find_or_initialize_by(abbreviation: "T#{t}") + task_definition.update!( + name: "Task #{t}", + description: "Sample task #{t} for PPI dashboard testing.", + weighting: BigDecimal('1'), + target_grade: 0, + start_date: unit.start_date, + target_date: unit.start_date + t.weeks, + upload_requirements: [{ key: 'file0', name: 'Document', type: 'document' }] + ) + task_definition + end + + seeded_projects = [] + seeded_tasks = [] + + (1..classes_per_unit).each do |class_num| + tutor_username = "ppi_tutor_u#{unit_num}c#{class_num}" + tutor = ppi_find_or_create_user(tutor_username, "Tutor#{unit_num}#{class_num}", 'PPI', Role.tutor_id) + unit.employ_staff(tutor, Role.tutor) + + tutorial_abbrev = "PPI-U#{unit_num}-C#{class_num}" + tutorial_capacity = students_per_grade * grades.length + tutorial = unit.tutorials.find_by(abbreviation: tutorial_abbrev) || unit.add_tutorial( + weekdays[class_num - 1], + '10:00', + "EN1-0#{class_num}", + tutor, + campus, + tutorial_capacity, + tutorial_abbrev + ) + grades.each do |target_grade| + students_per_grade.times do |i| + # Keep the original four-per-grade usernames assigned to their + # existing grade when this task repairs a previously seeded DB. + if i < legacy_students_per_grade + student_index = (target_grade * legacy_students_per_grade) + i + 1 + username = "ppi_u#{unit_num}c#{class_num}s#{student_index.to_s.rjust(2, '0')}" + elsif i < baseline_students_per_grade + legacy_total = grades.length * legacy_students_per_grade + baseline_added_per_grade = baseline_students_per_grade - legacy_students_per_grade + student_index = legacy_total + (target_grade * baseline_added_per_grade) + + (i - legacy_students_per_grade) + 1 + username = "ppi_u#{unit_num}c#{class_num}s#{student_index.to_s.rjust(2, '0')}" + else + student_index = 100 + (target_grade * 100) + i + 1 + username = "ppi_u#{unit_num}c#{class_num}g#{target_grade}s#{(i + 1).to_s.rjust(2, '0')}" + end + student = ppi_find_or_create_user(username, "Student#{student_index}", grade_labels[target_grade], Role.student_id) + + project = unit.enrol_student(student, campus) + project.update!(target_grade: target_grade) + project.enrol_in(tutorial) + seeded_projects << project + + # Populate the full lifecycle on every task/grade cohort. Rotating + # the extra members across tasks keeps the advanced bars varied, + # while ensuring redo and resubmission states are always demoable. + task_defs.each_with_index do |td, td_idx| + task = project.task_for_task_definition(td) + seeded_tasks << task + + cohort_ordinal = ((class_num - 1) * students_per_grade) + i + status_key = PPI_SAMPLE_LIFECYCLE_STATUSES.fetch( + (cohort_ordinal + td_idx + target_grade + unit_num) % + PPI_SAMPLE_LIFECYCLE_STATUSES.length + ) + status = TaskStatus.public_send(status_key) + uploaded = PPI_SAMPLE_UPLOADED_STATUSES.include?(status_key) + submitted_at = uploaded ? Time.zone.now - 1.day : nil + + task.update!( + task_status: status, + file_uploaded_at: submitted_at, + submission_date: submitted_at, + completion_date: + status_key == :complete ? 1.day.ago.to_date : nil + ) + end + + project.update_task_stats + end + end + + repaired_capacity = [tutorial_capacity, tutorial.num_students].max + tutorial.update!(capacity: repaired_capacity) if tutorial.capacity != repaired_capacity + end + + cohort_sizes = grades.index_with do |target_grade| + unit.active_projects.where(target_grade: target_grade).count + end + unless cohort_sizes.values.all? do |size| + size - 1 >= minimum_cohort_size + end + raise "#{unit.code} PPI cohorts are below the configured threshold: #{cohort_sizes.inspect}" + end + + expected_project_count = classes_per_unit * grades.length * students_per_grade + unless unit.active? && unit.peer_progress_enabled? && + seeded_projects.uniq.count == expected_project_count && + seeded_projects.all? { |project| project.enrolled? && project.user.role_id == Role.student_id } + raise "#{unit.code} PPI demo projects are not active student enrolments" + end + + expected_task_count = expected_project_count * task_defs.length + tasks_released = seeded_tasks.uniq.count == expected_task_count && seeded_tasks.all? do |task| + task.local_start_date.present? && + task.local_start_date <= Time.zone.now && + task.task_definition.target_grade <= task.project.target_grade + end + unless task_defs.all? { |task_definition| task_definition.target_grade.zero? } && tasks_released + raise "#{unit.code} PPI demo tasks are not released at the pass target grade" + end + + snapshots = PeerProgressAggregationService.call(unit: unit) + task_definition_ids = task_defs.map(&:id) + demo_snapshots = snapshots.select do |snapshot| + task_definition_ids.include?(snapshot.task_definition_id) && grades.include?(snapshot.target_grade) + end + expected_snapshot_count = task_defs.length * grades.length + latest_grade_changes = grades.index_with do |target_grade| + unit.active_projects.where(target_grade: target_grade).maximum(:target_grade_changed_at) + end + fresh_after = stale_after_hours.hours.ago + + snapshots_valid = demo_snapshots.count == expected_snapshot_count && + demo_snapshots.map { |snapshot| [snapshot.task_definition_id, snapshot.target_grade] }.uniq.count == expected_snapshot_count && + demo_snapshots.all? do |snapshot| + latest_change = latest_grade_changes.fetch(snapshot.target_grade) + snapshot.cohort_size == cohort_sizes.fetch(snapshot.target_grade) && + snapshot.submitted_count.is_a?(Integer) && + !snapshot.submitted_percentage.nil? && + ppi_viewer_vectors_safe?( + unit: unit, + snapshot: snapshot, + minimum_cohort_size: minimum_cohort_size + ) && + snapshot.calculated_at >= fresh_after && + (latest_change.nil? || snapshot.calculated_at >= latest_change) + end + raise "#{unit.code} PPI demo snapshots failed post-seed validation" unless snapshots_valid + + puts "-> #{unit.code}: #{unit.tutorials.count} classes, #{unit.projects.count} students, " \ + "#{task_defs.count} tasks, peer-safe cohorts verified, " \ + "#{demo_snapshots.count} demo snapshots" + end + + puts 'PPI sample dashboard data ready.' + end +end 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..a892cde94c --- /dev/null +++ b/test/api/api_root_test.rb @@ -0,0 +1,82 @@ +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 ships with no authentication and nothing +# fails. 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 + 'AuthenticationApi', # sign in, cannot require a session + 'CampusesPublicApi', # read-only list of campuses + 'D2lIntegrationApi::OauthPublicApi', # OAuth callback from D2L + 'SettingsPublicApi', # branding and feature flags for the login page + '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.select { |line| line.match?(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.reject { |line| line.match?(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/auth_test.rb b/test/api/auth_test.rb index cc3f737601..f37ef944d3 100644 --- a/test/api/auth_test.rb +++ b/test/api/auth_test.rb @@ -9,6 +9,19 @@ def app Rails.application end + setup do + Rack::Attack.reset! + end + + def post_failed_auth(username:, ip:) + post( + '/api/auth.json', + { username: username, password: 'definitely-wrong-password' }.to_json, + 'CONTENT_TYPE' => 'application/json', + 'REMOTE_ADDR' => ip + ) + end + # --------------------------------------------------------------------------- # # --- Endpoint testing for: # ------- /api/auth.json @@ -19,6 +32,9 @@ def app # Test POST for new authentication token def test_auth_post + expected_auth = User.first + expected_auth.update!(theme_preference: 'dark') + data_to_post = { username: 'aadmin', password: 'password', @@ -27,7 +43,6 @@ def test_auth_post # Get response back for logging in with username 'aadmin' password 'password' post_json '/api/auth.json', data_to_post actual_auth = last_response_body - expected_auth = User.first # Check that response contains a user. assert actual_auth.key?('user'), 'Expect response to have a user' @@ -37,10 +52,13 @@ def test_auth_post # Check that the returned user has the required details. # These match the model object... so can compare in loops - user_keys = %w[id email first_name last_name username nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup] + user_keys = %w[id email first_name last_name username nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications display_peer_progress opt_in_to_research has_run_first_time_setup theme_preference] # Check the returned user matches the expected database value assert_json_matches_model(expected_auth, response_user_data, user_keys) + assert_in_delta expected_auth.theme_preference_updated_at.to_f, + Time.iso8601(response_user_data['theme_preference_updated_at']).to_f, + 0.001 # Check other values returned assert_equal expected_auth.role.name, response_user_data['system_role'], 'Roles match' @@ -114,6 +132,40 @@ def test_fail_password_auth assert actual_auth.key? 'error' end + def test_repeated_failed_password_auth_is_rate_limited_by_ip + travel_to Time.zone.parse('2026-08-27 03:00:30 UTC') do + 6.times do |attempt| + post_failed_auth( + username: "missing-user-#{attempt}", + ip: '192.0.2.10' + ) + + assert_equal(attempt < 5 ? 401 : 429, last_response.status) + end + + assert_equal '30', last_response.headers.fetch('retry-after') + assert_equal 'Too many authentication attempts. Please try again later.', last_response_body['error'] + end + end + + def test_repeated_failed_password_auth_is_rate_limited_by_normalized_json_username + username = User.first.username + username_variants = [username, username.upcase, " #{username}", "#{username} ", " #{username.upcase} "] + + travel_to Time.zone.parse('2026-08-27 03:00:30 UTC') do + username_variants.each_with_index do |attempted_username, attempt| + post_failed_auth(username: attempted_username, ip: "198.51.100.#{attempt + 1}") + assert_equal 401, last_response.status + end + + post_failed_auth(username: username, ip: '198.51.100.6') + + assert_equal 429, last_response.status + assert_equal '30', last_response.headers.fetch('retry-after') + assert_equal 'Too many authentication attempts. Please try again later.', last_response_body['error'] + end + end + # Test auth with empty request body def test_fail_empty_request data_to_post = "" @@ -195,6 +247,7 @@ def test_auth_delete def test_refresh_token user = FactoryBot.create(:user) + user.update!(theme_preference: 'dark') token = user.generate_authentication_token!(token_type: :refresh_token) count = user.auth_tokens.count @@ -205,6 +258,10 @@ def test_refresh_token post '/api/auth/access-token', { remember: true } assert_equal 201, last_response.status + assert_equal 'dark', last_response_body.dig('user', 'theme_preference') + assert_in_delta user.theme_preference_updated_at.to_f, + Time.iso8601(last_response_body.dig('user', 'theme_preference_updated_at')).to_f, + 0.001 assert_equal count + 1, user.auth_tokens.count new_token = user.auth_tokens.last diff --git a/test/api/authentication_api_test.rb b/test/api/authentication_api_test.rb new file mode 100644 index 0000000000..caf70871f5 --- /dev/null +++ b/test/api/authentication_api_test.rb @@ -0,0 +1,170 @@ +require 'test_helper' +require 'securerandom' + +# +# Tests that a federated sign in resolves to the person the assertion is +# actually about. The LTI callback is the federated path that is mounted in the +# test environment, so it stands in for the SAML and AAF callbacks and for the +# LTI membership import job, which share the same lookup helper. +# +class AuthenticationApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def lti_token_for(member) + JWT.encode({ + member: member, + exp: Time.now.to_i + 30, + jti: SecureRandom.uuid + }, Doubtfire::Application.config.lti_api_secret, 'HS256') + end + + def lti_member(login_id:, email:) + { + user_id: SecureRandom.uuid, + name: 'Nickname', + given_name: 'First name', + family_name: 'Last name', + email: email, + ext_user_username: login_id, + roles: ['Learner'] + } + end + + # An assertion whose login_id matches an existing account resolves to it. + def test_assertion_resolves_on_matching_login_id + user = FactoryBot.create(:user, username: 'sec07-known', email: 'sec07-known@example.com') + user.update(login_id: 'sec07-known-login') + + user_count = User.count + + post '/api/auth/lti', { ltik: lti_token_for(lti_member(login_id: 'sec07-known-login', email: 'sec07-known@example.com')) } + + assert_equal 201, last_response.status, last_response_body + assert_equal user.username, last_response_body['username'] + assert_equal user_count, User.count, 'Matching on login_id must not create a user' + end + + # Once an account and an assertion both carry a login id, that identifier has + # to decide the match on its own. Falling through to a shared or reused email + # address after a mismatch would issue a login token for the wrong account. + def test_assertion_does_not_fall_back_to_email_after_a_login_id_mismatch + account = FactoryBot.create(:user, username: 'sec07-email-owner', email: 'sec07-shared@example.com') + account.update(login_id: 'sec07-email-owner-login') + + user_count = User.count + + post '/api/auth/lti', { + ltik: lti_token_for(lti_member(login_id: 'sec07-other-login', email: 'sec07-shared@example.com')) + } + + assert_equal 500, last_response.status, 'A mismatched asserted login id must not be rescued by the email' + assert_nil last_response_body['auth_token'], 'No token may be issued for the email owner' + assert_equal user_count, User.count, 'A refused assertion must not create an account' + + account.reload + assert_equal 'sec07-email-owner-login', account.login_id + assert_nil account.auth_tokens.first, 'No token may be issued for the unrelated account' + end + + # An assertion whose derived username collides with an unrelated account must + # not resolve to that account. The derived username is the local part of the + # asserted email, so two people at different domains derive the same one. + # Nothing the provider asserted matches, so the callback tries to create an + # account and the username the assertion derives is already taken. + def test_assertion_does_not_resolve_on_a_colliding_derived_username + unrelated = FactoryBot.create(:user, username: 'sec07-shared', email: 'sec07-shared@one.example.com') + unrelated.update(login_id: 'sec07-unrelated-login') + + user_count = User.count + + post '/api/auth/lti', { ltik: lti_token_for(lti_member(login_id: 'sec07-other-login', email: 'sec07-shared@two.example.com')) } + + assert_equal 500, last_response.status, 'A colliding assertion must be refused, not resolved' + assert_nil last_response_body['auth_token'], 'No token may be issued on a refused assertion' + assert_equal user_count, User.count, 'A refused assertion must not create an account' + + unrelated.reload + assert_equal 'sec07-unrelated-login', unrelated.login_id, 'The unrelated account must not be taken over' + assert_equal 'sec07-shared@one.example.com', unrelated.email, 'The unrelated account must not be rewritten' + assert_nil unrelated.auth_tokens.first, 'No token may be issued for the unrelated account' + end + + # An account created before the institution had an identity provider is + # adopted at its first federated sign in on the asserted email, so removing + # the username lookup does not orphan it. + def test_pre_federation_account_is_adopted_on_the_asserted_email + legacy = FactoryBot.create(:user, username: 'sec07-legacy', email: 'sec07-legacy@example.com') + legacy.update(login_id: nil) + + user_count = User.count + + post '/api/auth/lti', { ltik: lti_token_for(lti_member(login_id: 'sec07-legacy-login', email: 'sec07-legacy@example.com')) } + + assert_equal 201, last_response.status, last_response_body + assert_equal legacy.username, last_response_body['username'] + assert_equal user_count, User.count, 'A pre federation account must be adopted, not duplicated' + + legacy.reload + assert_equal 'sec07-legacy-login', legacy.login_id + end + + # A pre federation account whose stored email is not the asserted one is no + # longer adopted on its username, because the username is not asserted. The + # sign in is refused and an administrator has to correct the stored email or + # login_id before that person can sign in. + def test_pre_federation_account_with_a_different_stored_email_is_not_adopted + legacy = FactoryBot.create(:user, username: 'sec07-moved', email: 'sec07-moved@old.example.com') + legacy.update(login_id: nil) + + user_count = User.count + + post '/api/auth/lti', { ltik: lti_token_for(lti_member(login_id: 'sec07-moved-login', email: 'sec07-moved@new.example.com')) } + + assert_equal 500, last_response.status, 'An unasserted username must not resolve the account' + assert_equal user_count, User.count + + legacy.reload + assert_nil legacy.login_id, 'The account must not have a login_id installed on it' + assert_equal 'sec07-moved@old.example.com', legacy.email + end + + # A legacy account with no email recorded holds a username but nothing the + # provider can assert, so an assertion that derives that username must not + # pick it up either. Matching it would hand the account to whoever registers + # the same local part at any domain. + def test_legacy_account_with_a_blank_email_is_not_taken_over + legacy = FactoryBot.create(:user, username: 'sec07-blank', email: 'sec07-blank@example.com') + legacy.update(login_id: nil) + legacy.update_column(:email, '') + + user_count = User.count + + post '/api/auth/lti', { ltik: lti_token_for(lti_member(login_id: 'sec07-attacker-login', email: 'sec07-blank@attacker.example.com')) } + + assert_equal 500, last_response.status, 'A blank email account must not be matched on its username' + assert_nil last_response_body['auth_token'], 'No token may be issued on a refused assertion' + assert_equal user_count, User.count + + legacy.reload + assert_nil legacy.login_id, 'The blank email account must not be taken over' + assert_nil legacy.auth_tokens.first, 'No token may be issued for the blank email account' + end + + # The normal path. A first time user is still created. + def test_first_time_user_is_still_created + user_count = User.count + + post '/api/auth/lti', { ltik: lti_token_for(lti_member(login_id: 'sec07-new-login', email: 'sec07-new@example.com')) } + + assert_equal 201, last_response.status, last_response_body + assert_equal 'sec07-new', last_response_body['username'] + assert_equal user_count + 1, User.count + + created = User.find_by(username: 'sec07-new') + assert_not_nil created + assert_equal 'sec07-new-login', created.login_id + assert_equal 'sec07-new@example.com', created.email + end +end diff --git a/test/api/authentication_refresh_cookie_test.rb b/test/api/authentication_refresh_cookie_test.rb new file mode 100644 index 0000000000..48f79c4616 --- /dev/null +++ b/test/api/authentication_refresh_cookie_test.rb @@ -0,0 +1,84 @@ +require 'test_helper' + +class AuthenticationRefreshCookieTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + Rack::Attack.reset! + end + + def test_remembered_login_rotates_refresh_token_at_renewal_boundary + travel_to Time.zone.parse('2026-08-28 12:00:00 UTC') do + user = FactoryBot.create(:user) + renewal_boundary = Time.zone.now + 12.hours + old_token = user.generate_authentication_token!( + expiry: renewal_boundary, + token_type: :refresh_token + ) + + post_remembered_login(user) + + refresh_tokens = user.auth_tokens.where(token_type: :refresh_token).order(:id) + new_token = refresh_tokens.last + + assert_equal 2, refresh_tokens.count + assert_not_equal old_token.id, new_token.id + assert_operator new_token.auth_token_expiry, :>, renewal_boundary + assert_match(/refresh_token=#{new_token.authentication_token};/, last_response.cookies['refresh_token'].to_s) + end + end + + def test_remembered_login_reuses_refresh_token_outside_renewal_window + travel_to Time.zone.parse('2026-08-28 12:00:00 UTC') do + user = FactoryBot.create(:user) + old_token = user.generate_authentication_token!( + expiry: Time.zone.now + 12.hours + 1.second, + token_type: :refresh_token + ) + + post_remembered_login(user) + + refresh_tokens = user.auth_tokens.where(token_type: :refresh_token).order(:id) + + assert_equal [old_token.id], refresh_tokens.pluck(:id) + assert_match(/refresh_token=#{old_token.authentication_token};/, last_response.cookies['refresh_token'].to_s) + end + end + + def test_remembered_login_rotates_expired_refresh_token + travel_to Time.zone.parse('2026-08-28 12:00:00 UTC') do + user = FactoryBot.create(:user) + old_token = user.generate_authentication_token!( + expiry: Time.zone.now - 1.second, + token_type: :refresh_token + ) + + post_remembered_login(user) + + refresh_tokens = user.auth_tokens.where(token_type: :refresh_token).order(:id) + new_token = refresh_tokens.last + + assert_equal 2, refresh_tokens.count + assert_not_equal old_token.id, new_token.id + assert_operator new_token.auth_token_expiry, :>, Time.zone.now + assert_match(/refresh_token=#{new_token.authentication_token};/, last_response.cookies['refresh_token'].to_s) + end + end + + private + + def post_remembered_login(user) + post_json '/api/auth.json', { + username: user.username, + password: 'password', + remember: true + } + + assert_equal 201, last_response.status + end +end diff --git a/test/api/comments/comment_test.rb b/test/api/comments/comment_test.rb index 961b2a5e0d..4dd7959b6b 100644 --- a/test/api/comments/comment_test.rb +++ b/test/api/comments/comment_test.rb @@ -564,12 +564,120 @@ def test_post_comment_empty_attachment post "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", comment_data - assert_equal 500, last_response.status + assert_equal 400, last_response.status, last_response_body assert_equal pre_count, TaskComment.count, 'No comment should be created' assert_equal 'Attachment is empty.', last_response_body['error'] end + def test_post_comment_oversized_attachment + project = Project.first + task_definition = project.unit.task_definitions.first + pre_count = TaskComment.count + + add_auth_header_for(user: project.student) + + attachment = upload_file('test_files/submissions/00_question.pdf', 'application/pdf') + File.stub :size?, 30_000_001 do + post "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", { attachment: attachment } + end + + assert_equal 413, last_response.status, last_response_body + assert_equal pre_count, TaskComment.count, 'No comment should be created' + assert_equal 'Attachment exceeds the maximum attachment size of 30MB.', last_response_body['error'] + end + + # Builds a group task definition for the given group_set. + def make_group_task_definition(unit, group_set) + td = TaskDefinition.new(unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: "pr_file_01_group_task_#{group_set.id}", + description: 'group attachment access', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 1.week, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.week, + abbreviation: "PRFILE01_#{group_set.id}", + restrict_status_updates: false, + upload_requirements: [ { 'key' => 'file0', 'name' => 'Doc', 'type' => 'document' } ], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + group_set: group_set) + td.save! + td + end + + # Builds a single group of `members` and returns [unit, group, task_definition]. + def build_group_task(members: 2) + unit = FactoryBot.create :unit + group_set = GroupSet.create!(name: 'pr_file_01_group_set', unit: unit) + group = Group.create!(group_set: group_set, name: 'pr_file_01_group', tutorial: unit.tutorials.first) + members.times { |i| group.add_member(unit.active_projects[i]) } + group.save! + + [unit, group, make_group_task_definition(unit, group_set)] + end + + # A group member posts an image attachment. Another member of the same group must be + # able to open it. The attachment is on the author's task instance, but the whole group + # shares one group_submission, so the fetch has to look through all_comments, not the + # caller's own task's comments (which was returning ActiveRecord::RecordNotFound -> 404). + def test_group_member_can_open_another_members_attachment + _unit, group, td = build_group_task + + author = group.projects.first + reader = group.projects.second + + add_auth_header_for(user: author.student) + post "/api/projects/#{author.id}/task_def_id/#{td.id}/comments", + { attachment: upload_file('test_files/submissions/Deakin_Logo.jpeg', 'image/jpeg') } + assert_equal 201, last_response.status, last_response.body + comment_id = last_response_body['id'] + + # The other member opens the attachment through their own project. + add_auth_header_for(user: reader.student) + get "/api/projects/#{reader.id}/task_def_id/#{td.id}/comments/#{comment_id}" + assert_equal 200, last_response.status, last_response.body + + TaskComment.find(comment_id).destroy + end + + # The widened lookup must stay bounded to the caller's own group. A member of a + # different group in the same group_set, querying their own project (so the :get + # check passes), still cannot reach the first group's comment: all_comments is scoped + # by that caller's own group_submission, so the id is not found and the API returns 404. + def test_attachment_lookup_stays_within_callers_group + unit = FactoryBot.create :unit + group_set = GroupSet.create!(name: 'pr_file_01_two_groups', unit: unit) + group_a = Group.create!(group_set: group_set, name: 'pr_file_01_group_a', tutorial: unit.tutorials.first) + group_b = Group.create!(group_set: group_set, name: 'pr_file_01_group_b', tutorial: unit.tutorials.first) + group_a.add_member(unit.active_projects[0]) + group_b.add_member(unit.active_projects[1]) + td = make_group_task_definition(unit, group_set) + + author = group_a.projects.first + outsider = group_b.projects.first + + add_auth_header_for(user: author.student) + post "/api/projects/#{author.id}/task_def_id/#{td.id}/comments", + { attachment: upload_file('test_files/submissions/Deakin_Logo.jpeg', 'image/jpeg') } + assert_equal 201, last_response.status, last_response.body + comment_id = last_response_body['id'] + + # The other group's member posts on their own group task, so their task and + # group_submission exist, then tries to open group A's attachment. + add_auth_header_for(user: outsider.student) + post_json "/api/projects/#{outsider.id}/task_def_id/#{td.id}/comments", comment: 'group b note' + assert_equal 201, last_response.status, last_response.body + + get "/api/projects/#{outsider.id}/task_def_id/#{td.id}/comments/#{comment_id}" + assert_equal 404, last_response.status, last_response.body + + TaskComment.find(comment_id).destroy + end + def test_read_receipts_for_task_status_comments project = Project.first user = project.student @@ -704,4 +812,115 @@ def test_discussed_in_class_task_comments_dont_show_in_inbox td.destroy! end + + # Marking a comment as unread must delete the caller's read receipt and succeed. + # remove_comment_read_entry used to call delete_all with a conditions hash, which raises + # ArgumentError on Rails 8 and turned every mark-as-unread into a 500. + def test_mark_comment_as_unread_removes_the_read_receipt + project = FactoryBot.create(:project) + unit = project.unit + user = project.student + convenor = unit.main_convenor_user + task_definition = unit.task_definitions.first + task = project.task_for_task_definition(task_definition) + + comment = task.add_text_comment(convenor, 'Please look at this') + comment.mark_as_read(user) + assert comment.read_by?(user), 'Comment should be read before it is marked unread' + assert_equal 1, CommentsReadReceipts.where(user: user, task_comment: comment).count + + add_auth_header_for user: user + post "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment.id}" + + assert_equal 201, last_response.status, last_response_body + assert_equal 0, CommentsReadReceipts.where(user: user, task_comment: comment).count, 'Read receipt should be gone' + assert_not comment.reload.read_by?(user), 'Comment should be unread after the request' + end + + def test_group_member_can_mark_shared_comment_unread_without_affecting_other_receipts + fixture = grouped_comment_fixture + comment = fixture[:comment] + author = fixture[:first_project].student + caller = fixture[:second_project].student + + comment.mark_as_read(caller) + assert comment.read_by?(author), "the comment author's receipt should exist" + assert comment.read_by?(caller), "the other group member's receipt should exist" + + add_auth_header_for user: caller + post "/api/projects/#{fixture[:second_project].id}/task_def_id/#{fixture[:task_definition].id}/comments/#{comment.id}" + + assert_equal 201, last_response.status, last_response_body + assert_not comment.reload.read_by?(caller), "only the caller's receipt should be removed" + assert comment.read_by?(author), "another group member's receipt must remain" + end + + def test_member_of_another_group_cannot_mark_comment_unread + fixture = grouped_comment_fixture + comment = fixture[:comment] + outsider = fixture[:other_project].student + + # Give the other group its own submission so all_comments is explicitly + # scoped to that group submission rather than the individual task. + fixture[:other_project] + .task_for_task_definition(fixture[:task_definition]) + .ensured_group_submission + + comment.mark_as_read(outsider) + assert comment.read_by?(outsider) + + add_auth_header_for user: outsider + post "/api/projects/#{fixture[:other_project].id}/task_def_id/#{fixture[:task_definition].id}/comments/#{comment.id}" + + assert_equal 404, last_response.status, last_response_body + assert comment.reload.read_by?(outsider), 'a rejected request must not change receipts' + end + + # A user with no submission rights on the project cannot mark its comments unread. + def test_mark_comment_as_unread_rejects_an_unauthorised_user + project = FactoryBot.create(:project) + unit = project.unit + convenor = unit.main_convenor_user + task_definition = unit.task_definitions.first + task = project.task_for_task_definition(task_definition) + comment = task.add_text_comment(convenor, 'Private thread') + + outsider = FactoryBot.create(:project).student + + add_auth_header_for user: outsider + post "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment.id}" + + assert_equal 403, last_response.status, last_response_body + end + + private + + def grouped_comment_fixture + unit = FactoryBot.create(:unit, student_count: 3, task_count: 0) + first_project, second_project, other_project = unit.active_projects.first(3) + group_set = FactoryBot.create(:group_set, unit: unit) + shared_group = FactoryBot.create(:group, group_set: group_set, tutorial: unit.tutorials.first) + other_group = FactoryBot.create(:group, group_set: group_set, tutorial: unit.tutorials.first) + + shared_group.add_member(first_project) + shared_group.add_member(second_project) + other_group.add_member(other_project) + + task_definition = FactoryBot.create( + :task_definition, + unit: unit, + group_set: group_set, + outcome_count: 0 + ) + task = first_project.task_for_task_definition(task_definition) + comment = task.add_text_comment(first_project.student, 'Shared group feedback') + + { + first_project: first_project, + second_project: second_project, + other_project: other_project, + task_definition: task_definition, + comment: comment + } + 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/d2l_test.rb b/test/api/d2l_test.rb index 37eadc7ab3..8587772cb3 100644 --- a/test/api/d2l_test.rb +++ b/test/api/d2l_test.rb @@ -109,6 +109,38 @@ def test_can_update_d2l_details_for_unit assert_equal '54321', unit.d2l_assessment_mapping.org_unit_id end + # A unit with no mapping used to reach d2l.id on nil and answer 500. Both routes + # must now report 404 instead. + def test_delete_d2l_without_mapping_returns_404 + unit = FactoryBot.create(:unit, with_students: false) + add_auth_header_for(user: unit.main_convenor_user) + + delete "/api/units/#{unit.id}/d2l/1" + assert_equal 404, last_response.status, last_response.inspect + assert_nil unit.reload.d2l_assessment_mapping + end + + def test_update_d2l_without_mapping_returns_404 + unit = FactoryBot.create(:unit, with_students: false) + add_auth_header_for(user: unit.main_convenor_user) + + put "/api/units/#{unit.id}/d2l/1", { org_unit_id: '54321' } + assert_equal 404, last_response.status, last_response.inspect + assert_nil unit.reload.d2l_assessment_mapping + end + + def test_delete_d2l_with_mismatched_id_returns_404 + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + add_auth_header_for(user: unit.main_convenor_user) + + initial_count = D2lAssessmentMapping.count + + delete "/api/units/#{unit.id}/d2l/#{d2l.id + 1}" + assert_equal 404, last_response.status, last_response.inspect + assert_equal initial_count, D2lAssessmentMapping.count + end + def test_can_login_to_d2l user = FactoryBot.create(:user, :convenor) add_auth_header_for(user: user) diff --git a/test/api/discussion_comment_api_test.rb b/test/api/discussion_comment_api_test.rb new file mode 100644 index 0000000000..4a5028f8db --- /dev/null +++ b/test/api/discussion_comment_api_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'test_helper' + +class DiscussionCommentApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::TestFileHelper + + def app + Rails.application + end + + setup do + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task = @project.task_for_task_definition(@task_definition) + @student = @project.student + @tutor = FactoryBot.create(:user, :tutor) + @unit.employ_staff(@tutor, Role.tutor) + end + + def test_create_discussion_comment_rejects_empty_attachment_with_bad_request + add_auth_header_for(user: @tutor) + comment_count = DiscussionComment.count + + post discussion_comments_endpoint, { + attachments: [upload_file('test_files/submissions/boo.png', 'audio/wav')] + } + + assert_equal 400, last_response.status, last_response_body + assert_equal 'Attachment is empty.', last_response_body['error'] + assert_equal comment_count, DiscussionComment.count + end + + def test_create_discussion_comment_rejects_oversized_attachment_with_payload_too_large + add_auth_header_for(user: @tutor) + comment_count = DiscussionComment.count + attachment = upload_file('test_files/submissions/00_question.pdf', 'audio/wav') + + File.stub :size?, 30_000_001 do + post discussion_comments_endpoint, { attachments: [attachment] } + end + + assert_equal 413, last_response.status, last_response_body + assert_equal 'Attachment exceeds the maximum attachment size of 30MB.', last_response_body['error'] + assert_equal comment_count, DiscussionComment.count + end + + def test_discussion_reply_rejects_empty_attachment_with_bad_request + discussion = create_discussion_comment + add_auth_header_for(user: @student) + + post discussion_reply_endpoint(discussion), { + attachment: upload_file('test_files/submissions/boo.png', 'audio/wav') + } + + assert_equal 400, last_response.status, last_response_body + assert_equal 'Attachment is empty.', last_response_body['error'] + assert_nil discussion.reload.time_discussion_completed + end + + def test_discussion_reply_rejects_oversized_attachment_with_payload_too_large + discussion = create_discussion_comment + add_auth_header_for(user: @student) + attachment = upload_file('test_files/submissions/00_question.pdf', 'audio/wav') + + File.stub :size?, 30_000_001 do + post discussion_reply_endpoint(discussion), { attachment: attachment } + end + + assert_equal 413, last_response.status, last_response_body + assert_equal 'Attachment exceeds the maximum attachment size of 30MB.', last_response_body['error'] + assert_nil discussion.reload.time_discussion_completed + end + + private + + def discussion_comments_endpoint + "/api/projects/#{@project.id}/task_def_id/#{@task_definition.id}/discussion_comments" + end + + def discussion_reply_endpoint(discussion) + "/api/projects/#{@project.id}/task_def_id/#{@task_definition.id}/comments/#{discussion.id}/discussion_comment/reply" + end + + def create_discussion_comment + DiscussionComment.create!( + task: @task, + user: @tutor, + recipient: @student, + content_type: 'discussion', + number_of_prompts: 1 + ) + end +end diff --git a/test/api/lti_api_test.rb b/test/api/lti_api_test.rb index 607c30a3b8..c31404becd 100644 --- a/test/api/lti_api_test.rb +++ b/test/api/lti_api_test.rb @@ -1,4 +1,5 @@ require 'test_helper' +require 'minitest/mock' require 'securerandom' require 'json' @@ -7,6 +8,36 @@ class LtiApiTest < ActiveSupport::TestCase include TestHelpers::AuthHelper include TestHelpers::JsonHelper + # Build the LTI member block that describes an existing Doubtfire user, using + # the identity fields the LTI routes map onto a user. + def lti_member_for(user, roles:) + { + user_id: user.id.to_s, + name: user.nickname || user.first_name, + given_name: user.first_name, + family_name: user.last_name, + email: user.email, + ext_user_username: user.login_id, + roles: roles + } + end + + def lti_user(trait) + FactoryBot.create(:user, trait, login_id: "lti-#{SecureRandom.hex(6)}") + end + + # Pass jti: nil to build a token that carries no JWT id at all. + def lti_enrol_token(unit, member, jti: SecureRandom.uuid) + payload = { + unit_id: unit.id, + member: member, + exp: Time.now.to_i + 30 + } + payload[:jti] = jti unless jti.nil? + + JWT.encode(payload, Doubtfire::Application.config.lti_api_secret, 'HS256') + end + def test_ensure_jwt_secret_is_valid # Simply validate that our ENV var is not nil secret_key = Doubtfire::Application.config.lti_api_secret @@ -231,14 +262,6 @@ def test_convenor_can_link_requested_unit end def test_correct_roles_are_enrolled - users = [ - FactoryBot.create(:user, :student), - FactoryBot.create(:user, :admin), - FactoryBot.create(:user, :convenor), - FactoryBot.create(:user, :auditor), - FactoryBot.create(:user, :tutor) - ] - roles_can_be_enrolled = %w[ Student Learner @@ -251,51 +274,304 @@ def test_correct_roles_are_enrolled unit = FactoryBot.create(:unit, with_students: false) - payload = { - unit_id: unit.id, - member: { - user_id: '2', - name: 'Nickname', - given_name: 'First name', - family_name: 'Last name', - email: 'email@doubtfire.com', - ext_user_username: 'student_test_lti', - roles: ['Learner'] - }, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - } - - secret_key = Doubtfire::Application.config.lti_api_secret - token = JWT.encode(payload, secret_key, 'HS256') - + # Each launch is presented by the person it was issued for, and each one + # carries its own token id. roles_cant_be_enrolled.each do |role| - payload[:member][:roles] = [role] - - token = JWT.encode(payload, secret_key, 'HS256') + user = lti_user(:student) + token = lti_enrol_token(unit, lti_member_for(user, roles: [role])) - add_auth_header_for(user: users.sample) + add_auth_header_for(user: user) post '/api/lti/enrol', { ltik: token } - assert_equal 204, last_response.status + assert_equal 204, last_response.status, last_response.body end roles_can_be_enrolled.each do |role| - payload[:member][:roles] = [role] + user = lti_user(:student) + token = lti_enrol_token(unit, lti_member_for(user, roles: [role])) - token = JWT.encode(payload, secret_key, 'HS256') - - add_auth_header_for(user: users.sample) # or whichever user you want as caller + add_auth_header_for(user: user) post '/api/lti/enrol', { ltik: token } - assert_equal 201, last_response.status + assert_equal 201, last_response.status, last_response.body id = last_response_body['id'] assert_not_nil id, "Expected project ID in response" project = Project.find(id) assert project.valid?, "Expected project to be created" assert_equal unit.id, project.unit.id + assert_equal user.id, project.user_id + end + end + + # The launch subject enrolling themselves is the ordinary path and must keep + # working. + def test_lti_enrol_binds_a_token_to_its_subject + unit = FactoryBot.create(:unit, with_students: false) + student = lti_user(:student) + + token = lti_enrol_token(unit, lti_member_for(student, roles: ['Learner'])) + + add_auth_header_for(user: student) + post '/api/lti/enrol', { ltik: token } + + assert_equal 201, last_response.status, last_response.body + + project = Project.find(last_response_body['id']) + assert_equal student.id, project.user_id + assert_equal unit.id, project.unit_id + end + + # A token issued for a member of staff must not give its bearer that staff + # role in the unit. + def test_lti_enrol_rejects_a_token_presented_by_another_user + unit = FactoryBot.create(:unit, with_students: false) + staff = lti_user(:tutor) + # Tutor capable, so without the check the token's Instructor role would + # actually land on them. + bearer = lti_user(:tutor) + + token = lti_enrol_token(unit, lti_member_for(staff, roles: ['Instructor'])) + + add_auth_header_for(user: bearer) + post '/api/lti/enrol', { ltik: token } + + unit.reload + assert_nil unit.unit_role_for(bearer), "Bearer of the token gained a unit role" + assert_nil unit.unit_role_for(staff), "Subject of the token gained a unit role" + assert_equal 0, unit.projects.where(user_id: bearer.id).count + + assert_equal 403, last_response.status, last_response.body + end + + # The web client carries the one launch token for the whole session, so the + # subject presenting it again has to keep working, and has to give the same + # enrolment back rather than a second one. + def test_lti_enrol_lets_the_launch_subject_present_the_same_token_again + unit = FactoryBot.create(:unit, with_students: false) + student = lti_user(:student) + jti = SecureRandom.uuid + + token = lti_enrol_token(unit, lti_member_for(student, roles: ['Learner']), jti: jti) + + add_auth_header_for(user: student) + + post '/api/lti/enrol', { ltik: token } + assert_equal 201, last_response.status, last_response.body + project_id = last_response_body['id'] + + post '/api/lti/enrol', { ltik: token } + assert_equal 201, last_response.status, last_response.body + assert_equal project_id, last_response_body['id'] + + assert_equal 1, unit.projects.where(user_id: student.id).count + assert_equal 1, ConsumedLtiToken.where(jti: jti).count + end + + # A token already spent by somebody else must not be spendable again, even by + # a caller the member fields now resolve to. + def test_lti_enrol_rejects_a_token_already_spent_by_another_user + unit = FactoryBot.create(:unit, with_students: false) + student = lti_user(:student) + other = lti_user(:student) + jti = SecureRandom.uuid + + ConsumedLtiToken.create!(jti: jti, user: other, expires_at: 1.minute.from_now) + + token = lti_enrol_token(unit, lti_member_for(student, roles: ['Learner']), jti: jti) + + add_auth_header_for(user: student) + post '/api/lti/enrol', { ltik: token } + + assert_equal 403, last_response.status, last_response.body + assert_equal 'This LTI token has already been used.', last_response_body['error'] + assert_equal 0, unit.projects.where(user_id: student.id).count + end + + # The replay record belongs to the account whose launch spent it. It must not + # turn the new foreign key into a reason an otherwise unused account cannot be + # deleted. + def test_consumed_lti_token_is_removed_with_its_user + user = lti_user(:student) + consumed = ConsumedLtiToken.create!(jti: SecureRandom.uuid, user: user, expires_at: 1.minute.from_now) + + user.destroy! + + assert_not ConsumedLtiToken.exists?(consumed.id) + end + + # The loser of a race on the unique index saw nothing recorded when it + # started, and still must not spend the token a second time. + def test_lti_enrol_rejects_a_concurrent_replay + unit = FactoryBot.create(:unit, with_students: false) + student = lti_user(:student) + + token = lti_enrol_token(unit, lti_member_for(student, roles: ['Learner'])) + + add_auth_header_for(user: student) + + duplicate = ->(*_args, **_kwargs) { raise ActiveRecord::RecordNotUnique, 'Duplicate entry' } + ConsumedLtiToken.stub(:create!, duplicate) do + post '/api/lti/enrol', { ltik: token } + end + + assert_equal 403, last_response.status, last_response.body + assert_equal 'This LTI token has already been used.', last_response_body['error'] + assert_equal 0, unit.projects.where(user_id: student.id).count + end + + # A unique index failure from anywhere else in the enrolment is not a replay + # and must not be reported as one. + def test_lti_enrol_does_not_report_an_unrelated_unique_failure_as_a_replay + unit = FactoryBot.create(:unit, with_students: false) + student = lti_user(:student) + + token = lti_enrol_token(unit, lti_member_for(student, roles: ['Learner'])) + + add_auth_header_for(user: student) + + duplicate = lambda do |*_args, **_kwargs| + raise ActiveRecord::RecordNotUnique, "Duplicate entry for key 'index_projects_on_unit_id_and_user_id'" + end + + Project.stub(:create!, duplicate) do + post '/api/lti/enrol', { ltik: token } end + + assert_not_equal 403, last_response.status, last_response.body + assert_not_equal 'This LTI token has already been used.', last_response_body['error'] + end + + # A token whose member only lines up with the local part of somebody's email + # address names nobody. The platform asserts a login id and an email, and + # neither of those is the caller here. + def test_lti_enrol_rejects_a_token_matching_only_a_derived_username + unit = FactoryBot.create(:unit, with_students: false) + + local_part = "alex-#{SecureRandom.hex(4)}" + # Tutor capable, so the token's Instructor role would actually land on them. + caller_user = FactoryBot.create( + :user, + :tutor, + username: local_part, + login_id: "lti-#{SecureRandom.hex(6)}", + email: "#{local_part}@another.example" + ) + + jti = SecureRandom.uuid + member = { + user_id: "unseen-#{SecureRandom.hex(4)}", + name: 'New Staff', + given_name: 'New', + family_name: 'Staff', + email: "#{local_part}@provider.example", + ext_user_username: "new-staff-#{SecureRandom.hex(4)}", + roles: ['Instructor'] + } + + token = lti_enrol_token(unit, member, jti: jti) + + add_auth_header_for(user: caller_user) + post '/api/lti/enrol', { ltik: token } + + assert_equal 403, last_response.status, last_response.body + + unit.reload + assert_nil unit.unit_role_for(caller_user), "Caller gained the token's staff role" + assert_nil ConsumedLtiToken.find_by(jti: jti) + end + + # A token that names a login_id has said who it is for. If that does not match, + # a shared or reused email address must not let it bind anyway. + def test_lti_enrol_rejects_a_token_whose_login_id_does_not_match + unit = FactoryBot.create(:unit, with_students: false) + + shared_email = "shared-#{SecureRandom.hex(4)}@provider.example" + caller_user = FactoryBot.create( + :user, + :tutor, + username: "alex-#{SecureRandom.hex(4)}", + login_id: "lti-#{SecureRandom.hex(6)}", + email: shared_email + ) + + jti = SecureRandom.uuid + member = { + user_id: "unseen-#{SecureRandom.hex(4)}", + name: 'New Staff', + given_name: 'New', + family_name: 'Staff', + # Same address, but the platform is naming a different person. + email: shared_email, + ext_user_username: "new-staff-#{SecureRandom.hex(4)}", + roles: ['Instructor'] + } + + token = lti_enrol_token(unit, member, jti: jti) + + add_auth_header_for(user: caller_user) + post '/api/lti/enrol', { ltik: token } + + assert_equal 403, last_response.status, last_response.body + + unit.reload + assert_nil unit.unit_role_for(caller_user), "Caller gained the token's staff role on an email match alone" + assert_nil ConsumedLtiToken.find_by(jti: jti) + end + + # Recording token ids must not let a token through that carries no id. + def test_lti_enrol_rejects_a_token_without_a_jti + unit = FactoryBot.create(:unit, with_students: false) + student = lti_user(:student) + + token = lti_enrol_token(unit, lti_member_for(student, roles: ['Learner']), jti: nil) + + add_auth_header_for(user: student) + post '/api/lti/enrol', { ltik: token } + + assert_equal 403, last_response.status, last_response.body + assert_equal "Invalid LTI token.", last_response_body['error'] + assert_equal 0, unit.projects.where(user_id: student.id).count + end + + # An empty token id is no more recordable than a missing one, so it has to be + # turned away in the same place rather than blowing up on the insert. + def test_lti_enrol_rejects_a_token_with_an_empty_jti + unit = FactoryBot.create(:unit, with_students: false) + student = lti_user(:student) + + token = lti_enrol_token(unit, lti_member_for(student, roles: ['Learner']), jti: '') + + add_auth_header_for(user: student) + post '/api/lti/enrol', { ltik: token } + + assert_equal 403, last_response.status, last_response.body + assert_equal "Invalid LTI token.", last_response_body['error'] + assert_equal 0, unit.projects.where(user_id: student.id).count + end + + # An ordinary staff launch still employs the person it was issued for, with + # the role the token asks for. + def test_lti_enrol_employs_the_launch_subject_as_staff + unit = FactoryBot.create(:unit, with_students: false) + tutor = lti_user(:tutor) + + token = lti_enrol_token(unit, lti_member_for(tutor, roles: ['Instructor'])) + + add_auth_header_for(user: tutor) + post '/api/lti/enrol', { ltik: token } + + assert_equal 204, last_response.status, last_response.body + + # The dashboard mounts again with the same token after the unit is linked. + post '/api/lti/enrol', { ltik: token } + assert_equal 204, last_response.status, last_response.body + + unit.reload + assert_equal 1, unit.unit_roles.where(user_id: tutor.id).count + unit_role = unit.unit_role_for(tutor) + assert_not_nil unit_role, "Expected the launch subject to be employed" + assert_equal Role.tutor.id, unit_role.role_id end def test_enrol_students_bulk diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb new file mode 100644 index 0000000000..b39403f737 --- /dev/null +++ b/test/api/notifications_api_test.rb @@ -0,0 +1,156 @@ +require 'test_helper' + +# EN-T02: the notifications API endpoints in app/api/notifications_api.rb. +# +# Five routes: +# GET /api/notifications (list, optional unread_only) +# GET /api/notifications/unread_count +# PUT /api/notifications/:id/read +# PUT /api/notifications/read_all +# DELETE /api/notifications/:id +# +# Every route scopes through current_user.notifications, so one user can never +# touch another's notifications. That is the case worth proving. +# +# Run this file on its own, not the whole suite: the test database is the +# development database (DF_TEST_DB_DATABASE == doubtfire-dev), so a full run +# holds locks and rewrites seeded data. See item 11 in +# doubtfire-deploy/RUNNING-LOCALLY.md. +class NotificationsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + @user = FactoryBot.create(:user, :student) + @other = FactoryBot.create(:user, :student) + end + + # GET /api/notifications ---------------------------------------------------- + + def test_list_returns_the_users_notifications_newest_first + older = FactoryBot.create(:notification, user: @user, created_at: 2.days.ago) + newer = FactoryBot.create(:notification, user: @user, created_at: 1.hour.ago) + FactoryBot.create(:notification, user: @other) # must not appear + + add_auth_header_for(user: @user) + get '/api/notifications' + + assert_equal 200, last_response.status + + json = JSON.parse(last_response.body) + ids = json.map { |n| n['id'] } + + assert_equal 2, json.length, 'only the current user\'s notifications' + assert_equal [newer.id, older.id], ids, 'recent_first order' + end + + def test_list_unread_only_filters_out_read_notifications + unread = FactoryBot.create(:notification, user: @user) + FactoryBot.create(:notification, :read, user: @user) + + add_auth_header_for(user: @user) + get '/api/notifications', unread_only: true + + assert_equal 200, last_response.status + + json = JSON.parse(last_response.body) + + assert_equal 1, json.length + assert_equal unread.id, json.first['id'] + end + + # GET /api/notifications/unread_count --------------------------------------- + + def test_unread_count_counts_only_the_users_unread + FactoryBot.create_list(:notification, 2, user: @user) # unread + FactoryBot.create(:notification, :read, user: @user) # read, excluded + FactoryBot.create(:notification, user: @other) # other user, excluded + + add_auth_header_for(user: @user) + get '/api/notifications/unread_count' + + assert_equal 200, last_response.status + assert_equal 2, JSON.parse(last_response.body)['count'] + end + + # PUT /api/notifications/:id/read ------------------------------------------- + + def test_marking_a_notification_as_read + notification = FactoryBot.create(:notification, user: @user) + + add_auth_header_for(user: @user) + put "/api/notifications/#{notification.id}/read" + + assert_equal 200, last_response.status + assert_not_nil JSON.parse(last_response.body)['read_at'] + assert_not_nil notification.reload.read_at + end + + # PUT /api/notifications/read_all ------------------------------------------- + + def test_marking_all_as_read_clears_only_the_users_unread + FactoryBot.create_list(:notification, 3, user: @user) + others = FactoryBot.create(:notification, user: @other) + + add_auth_header_for(user: @user) + put '/api/notifications/read_all' + + assert_equal 200, last_response.status + assert JSON.parse(last_response.body)['success'] + assert_equal 0, @user.notifications.unread.count + assert_nil others.reload.read_at, 'another user\'s notifications are untouched' + end + + # DELETE /api/notifications/:id --------------------------------------------- + + def test_deleting_a_notification + notification = FactoryBot.create(:notification, user: @user) + + add_auth_header_for(user: @user) + + assert_difference 'Notification.count', -1 do + delete "/api/notifications/#{notification.id}" + end + + assert_equal 200, last_response.status + end + + # Cross-user isolation ------------------------------------------------------ + + def test_a_user_cannot_mark_another_users_notification_as_read + theirs = FactoryBot.create(:notification, user: @other) + + add_auth_header_for(user: @user) + put "/api/notifications/#{theirs.id}/read" + + assert_equal 404, last_response.status + assert_nil theirs.reload.read_at, 'it must stay unread' + end + + def test_a_user_cannot_delete_another_users_notification + theirs = FactoryBot.create(:notification, user: @other) + + add_auth_header_for(user: @user) + + assert_no_difference 'Notification.count' do + delete "/api/notifications/#{theirs.id}" + end + + assert_equal 404, last_response.status + end + + # Authentication ------------------------------------------------------------ + + def test_an_unauthenticated_request_is_rejected + clear_auth_header + + get '/api/notifications' + + assert_equal 419, last_response.status + 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/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb new file mode 100644 index 0000000000..7d115d0ec5 --- /dev/null +++ b/test/api/peer_progress_api_test.rb @@ -0,0 +1,1185 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'time' + +class PeerProgressApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + RESPONSE_KEYS = %w[ + task_definition_id + unit_id + target_grade + submitted_percentage + completed_percentage + status_distribution + distribution_available + distribution_unavailable_reason + is_suppressed + is_stale + is_feature_enabled + is_user_enabled + last_updated_at + unavailable_reason + unavailable_message + ].freeze + + FORBIDDEN_KEYS = %w[ + cohort_size + submitted_count + status_counts + count + user_id + student_id + username + first_name + last_name + project_id + task_status + marks + feedback + ].freeze + + setup do + clear_auth_header + + @original_minimum_cohort_size = + ENV.fetch('DF_PPI_MINIMUM_COHORT_SIZE', nil) + + @original_stale_after_hours = + ENV.fetch('DF_PPI_STALE_AFTER_HOURS', nil) + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = + PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE.to_s + ENV['DF_PPI_STALE_AFTER_HOURS'] = '48' + + @unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 1, + staff_count: 0, + outcome_count: 0 + ) + @unit.update!(peer_progress_enabled: true) + + @student = create(:user, :student) + @project = @unit.enrol_student( + @student, + @unit.tutorials.first.campus + ) + @project.update!(target_grade: 1) + @project.update!(target_grade_changed_at: 1.year.ago) + + @task_definition = create( + :task_definition, + unit: @unit, + target_grade: 0, + start_date: Time.zone.parse('2026-01-01 00:00:00 UTC'), + outcome_count: 0 + ) + end + + teardown do + restore_env( + 'DF_PPI_MINIMUM_COHORT_SIZE', + @original_minimum_cohort_size + ) + restore_env( + 'DF_PPI_STALE_AFTER_HOURS', + @original_stale_after_hours + ) + clear_auth_header + end + + test 'requires authentication' do + get endpoint + + assert_equal 419, last_response.status + assert_private_no_store + end + + test 'returns a privacy-safe normal response for the owning student' do + create_snapshot( + submitted_percentage: 60, + cohort_size: 25, + status_counts: safe_status_counts + ) + + request_as(@student) + + assert_equal 200, last_response.status, last_response.body + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal @task_definition.id, body['task_definition_id'] + assert_equal @unit.id, body['unit_id'] + assert_equal @project.target_grade, body['target_grade'] + assert_equal 60.0, body['submitted_percentage'] + assert_equal 10.0, body['completed_percentage'] + assert_equal true, body['distribution_available'] + assert_nil body['distribution_unavailable_reason'] + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + body['status_distribution'].pluck('status') + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert_equal true, body['is_user_enabled'] + assert body['last_updated_at'].present? + assert_nil body['unavailable_reason'] + assert_equal '', body['unavailable_message'] + end + + test 'excludes a submitted complete viewer before compact and detailed output' do + viewer_task = create( + :task, + project: @project, + task_definition: @task_definition, + task_status: TaskStatus.complete, + file_uploaded_at: 2.hours.ago, + submission_date: 2.hours.ago + ) + calculated_at = viewer_task.updated_at + 1.minute + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: @project.target_grade, + cohort_size: 22, + submitted_count: 1, + submitted_percentage: 4.55, + status_counts: empty_status_counts.merge( + 'not_started' => 21, + 'complete' => 1 + ), + calculated_at: calculated_at + ) + + request_as(@student) + + body = last_response_body + assert_equal 200, last_response.status + assert_equal 0.0, body['submitted_percentage'] + assert_equal 0.0, body['completed_percentage'] + assert_equal true, body['distribution_available'] + assert_equal 100.0, + distribution_percentage(body, 'not_started') + assert_equal 0.0, + distribution_percentage(body, 'complete') + end + + test 'excludes an unsubmitted viewer from a fully complete peer cohort' do + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: @project.target_grade, + cohort_size: 22, + submitted_count: 21, + submitted_percentage: 95.45, + status_counts: empty_status_counts.merge( + 'not_started' => 1, + 'complete' => 21 + ), + calculated_at: Time.zone.now + ) + + request_as(@student) + + body = last_response_body + assert_equal 200, last_response.status + assert_equal 100.0, body['submitted_percentage'] + assert_equal 100.0, body['completed_percentage'] + assert_equal true, body['distribution_available'] + assert_equal 0.0, + distribution_percentage(body, 'not_started') + assert_equal 100.0, + distribution_percentage(body, 'complete') + end + + test 'requires twenty one remaining peers rather than counting the viewer' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 20 + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal true, last_response_body['is_suppressed'] + assert_nil last_response_body['submitted_percentage'] + end + + test 'fails closed when the viewer task changed after aggregation' do + calculated_at = 1.hour.ago + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + calculated_at: calculated_at + ) + create( + :task, + project: @project, + task_definition: @task_definition, + task_status: TaskStatus.complete, + updated_at: calculated_at + 1.minute + ) + + request_as(@student) + + body = last_response_body + assert_equal 200, last_response.status + assert_nil body['submitted_percentage'] + assert_nil body['completed_percentage'] + assert_nil body['status_distribution'] + assert_equal 'snapshot_unavailable', body['unavailable_reason'] + end + + test 'fails closed when the viewer re-enrolled after aggregation' do + calculated_at = 1.hour.ago + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + calculated_at: calculated_at + ) + @project.update!(enrolled: false) + @project.update!(enrolled: true) + + request_as(@student) + + body = last_response_body + assert_equal 200, last_response.status + assert_nil body['submitted_percentage'] + assert_nil body['completed_percentage'] + assert_equal 'snapshot_unavailable', body['unavailable_reason'] + end + + test 'fails closed when a legacy snapshot has no exact submitted count' do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + submitted_count: nil + ) + + request_as(@student) + + body = last_response_body + assert_equal 200, last_response.status + assert_nil body['submitted_percentage'] + assert_nil body['completed_percentage'] + assert_nil body['status_distribution'] + assert_equal 'aggregation_incomplete', body['unavailable_reason'] + end + + test 'suppresses a detailed vector that jointly reveals exact counts' do + status_counts = empty_status_counts.merge( + 'not_started' => 6, + 'complete' => 18 + ) + create_snapshot( + submitted_percentage: 75, + cohort_size: 24, + status_counts: status_counts + ) + + request_as(@student) + + body = last_response_body + assert_equal 200, last_response.status + assert_equal 80.0, body['submitted_percentage'] + assert_equal 80.0, body['completed_percentage'] + assert_nil body['status_distribution'] + assert_equal false, body['distribution_available'] + assert_equal 'privacy_protection', + body['distribution_unavailable_reason'] + assert_nil body['unavailable_reason'] + end + + test 'honours a students disabled peer progress preference' do + @student.update!(display_peer_progress: false) + create_snapshot( + submitted_percentage: 60, + cohort_size: 25, + status_counts: safe_status_counts + ) + + request_as(@student) + + body = last_response_body + assert_equal 200, last_response.status + assert_nil body['submitted_percentage'] + assert_nil body['completed_percentage'] + assert_nil body['status_distribution'] + assert_equal false, body['distribution_available'] + assert_equal false, body['is_user_enabled'] + assert_equal 'user_disabled', body['unavailable_reason'] + assert_equal 'user_disabled', + body['distribution_unavailable_reason'] + end + + test 'returns a genuine zero as zero rather than unavailable' do + create_snapshot( + submitted_percentage: 0, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal 0.0, body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal '', body['unavailable_message'] + end + + test 'does not allow access before a student specific flexible start date' do + @unit.update!(allow_flexible_dates: true) + + create( + :task, + project: @project, + task_definition: @task_definition, + task_status: TaskStatus.not_started, + target_start_date: 1.day.from_now + ) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'does not allow access before a target grade specific start date' do + @unit.update!(allow_flexible_dates: true) + + TaskDefinitionGradeDueDate.create!( + task_definition: @task_definition, + target_grade: @project.target_grade, + start_date: 1.day.from_now, + target_due_date: @task_definition.target_date + ) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'allows access after the target grade specific start date' do + @unit.update!(allow_flexible_dates: true) + + TaskDefinitionGradeDueDate.create!( + task_definition: @task_definition, + target_grade: @project.target_grade, + start_date: 1.day.ago, + target_due_date: @task_definition.target_date + ) + + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 50.0, last_response_body['submitted_percentage'] + end + + test 'does not create a task row while checking the release date' do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + assert_no_difference('Task.count') do + request_as(@student) + end + + assert_equal 200, last_response.status + end + + test 'quantises the student percentage to ten point buckets' do + create_snapshot( + submitted_percentage: 61, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 60.0, last_response_body['submitted_percentage'] + end + + test 'fails closed when the cohort configuration is below the privacy floor' do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '20' + + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + assert_private_no_store + end + + test 'accepts a configured threshold above the privacy floor' do + configured_threshold = PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + 1 + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = configured_threshold.to_s + + create_snapshot( + submitted_percentage: 50, + cohort_size: configured_threshold + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 50.0, last_response_body['submitted_percentage'] + assert_equal false, last_response_body['is_suppressed'] + end + + test 'does not allow a student to read another students project' do + other_student = create(:user, :student) + other_project = @unit.enrol_student( + other_student, + @unit.tutorials.first.campus + ) + other_project.update!(target_grade: 1) + + request_as( + @student, + endpoint(project: other_project) + ) + + assert_peer_progress_not_found + end + + test 'does not allow a tutor to use the student endpoint' do + tutor = create(:user, :tutor) + @unit.employ_staff(tutor, Role.tutor) + + request_as(tutor) + + assert_peer_progress_not_found + end + + test 'does not allow an unenrolled project' do + @project.update!(enrolled: false) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'does not allow an inactive unit in the first release' do + @unit.update!(active: false) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'does not allow a task from another unit' do + other_unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 + ) + other_task = create( + :task_definition, + unit: other_unit, + target_grade: 0, + start_date: 1.day.ago, + outcome_count: 0 + ) + + request_as( + @student, + endpoint(task_definition: other_task) + ) + + assert_peer_progress_not_found + end + + test 'does not allow a task above the students target grade' do + higher_grade_task = create( + :task_definition, + unit: @unit, + target_grade: 2, + start_date: 1.day.ago, + outcome_count: 0 + ) + + request_as( + @student, + endpoint(task_definition: higher_grade_task) + ) + + assert_peer_progress_not_found + end + + test 'does not allow an unreleased task' do + future_task = create( + :task_definition, + unit: @unit, + target_grade: 0, + start_date: 1.day.from_now, + outcome_count: 0 + ) + + request_as( + @student, + endpoint(task_definition: future_task) + ) + + assert_peer_progress_not_found + end + + test 'returns a neutral unavailable state when no snapshot exists' do + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert body['unavailable_message'].present? + end + + test 'suppresses the formerly unsafe cohort of twenty' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 20 + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['submitted_percentage'] + assert_equal true, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert body['unavailable_message'].present? + assert_not body.key?('cohort_size') + end + + test 'shows a cohort at the exact configured threshold' do + create_snapshot( + submitted_percentage: 40, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal 40.0, body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + end + + test 'keeps half a bucket wider than one students share of the smallest cohort' do + # The zero and hundred edge buckets are only non-singletons while one + # student's share is smaller than half the bucket width. + assert_operator( + PeerProgressApi::PERCENTAGE_BUCKET_SIZE / 2.0, + :>, + 100.0 / PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + 'Half of PERCENTAGE_BUCKET_SIZE must exceed one student share, or an ' \ + 'edge bucket reveals the exact submitted count' + ) + end + + test 'does not let the quantised percentage reveal the submitted count' do + minimum = PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + + (minimum..1_000).each do |cohort_size| + singleton_buckets = quantised_count_groups(cohort_size).select do |_bucket, counts| + counts.one? + end + + assert_empty( + singleton_buckets, + "cohort #{cohort_size} exposes exact submitted counts" + ) + end + + floor_groups = quantised_count_groups(minimum) + assert_equal [0, 1], floor_groups.fetch(0.0) + assert_equal [minimum - 1, minimum], floor_groups.fetch(100.0) + end + + test 'hides the percentage when an active unit snapshot is stale' do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + calculated_at: 49.hours.ago + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal true, body['is_stale'] + assert body['last_updated_at'].present? + assert body['unavailable_message'].present? + end + + test 'returns a disabled state when the unit has disabled PPI' do + @unit.update!(peer_progress_enabled: false) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal false, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert body['unavailable_message'].present? + end + + test 'ignores a browser supplied target grade' do + create_snapshot( + submitted_percentage: 60, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + request_as( + @student, + "#{endpoint}?target_grade=3" + ) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal @project.target_grade, body['target_grade'] + assert_equal 60.0, body['submitted_percentage'] + end + + test 'returns a neutral unavailable state when no target grade is selected' do + # Intentionally bypass validations and callbacks to verify that the API + # safely handles a project with no stored target grade. + # rubocop:disable Rails/SkipsModelValidations + @project.update_column(:target_grade, nil) + # rubocop:enable Rails/SkipsModelValidations + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['target_grade'] + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert_equal( + PeerProgressApi::UNAVAILABLE_MESSAGE, + body['unavailable_message'] + ) + end + + test 'does not expose an invalid stored target grade' do + # Intentionally bypass validations and callbacks to verify that the API + # safely handles an invalid legacy target-grade value. + # rubocop:disable Rails/SkipsModelValidations + @project.update_column(:target_grade, 999) + # rubocop:enable Rails/SkipsModelValidations + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['target_grade'] + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert_equal( + PeerProgressApi::UNAVAILABLE_MESSAGE, + body['unavailable_message'] + ) + end + + test 'returns unavailable rather than zero for an empty stored cohort' do + create_snapshot( + submitted_percentage: nil, + cohort_size: 0 + ) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['submitted_percentage'] + assert_equal true, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert body['last_updated_at'].present? + assert body['unavailable_message'].present? + end + + test 'returns the snapshot timestamp in UTC ISO 8601 format' do + calculated_at = Time.zone.parse('2026-08-10 03:15:00 UTC') + + create_snapshot( + submitted_percentage: 62.5, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + calculated_at: calculated_at + ) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal( + calculated_at.utc.iso8601, + body['last_updated_at'] + ) + end + + test 'fails closed when the stale window configuration is missing' do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + ENV.delete('DF_PPI_STALE_AFTER_HOURS') + + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + assert_private_no_store + end + + test 'returns the same generic response for unknown project and task ids' do + unknown_project_id = Project.maximum(:id).to_i + 10_000 + + request_as( + @student, + "/api/projects/#{unknown_project_id}/task_def_id/" \ + "#{@task_definition.id}/peer_progress" + ) + + assert_peer_progress_not_found + + unknown_task_id = TaskDefinition.maximum(:id).to_i + 10_000 + + request_as( + @student, + "/api/projects/#{@project.id}/task_def_id/" \ + "#{unknown_task_id}/peer_progress" + ) + + assert_peer_progress_not_found + end + + test 'fails closed for invalid positive integer configuration' do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + + [ + ['DF_PPI_MINIMUM_COHORT_SIZE', '0'], + ['DF_PPI_MINIMUM_COHORT_SIZE', 'not-a-number'], + ['DF_PPI_STALE_AFTER_HOURS', '-1'], + ['DF_PPI_STALE_AFTER_HOURS', '1.5'] + ].each do |name, value| + original = ENV.fetch(name, nil) + + begin + ENV[name] = value + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + assert_private_no_store + ensure + restore_env(name, original) + end + end + end + + test 'keeps a snapshot available at the exact stale boundary' do + travel_to Time.zone.parse('2026-08-10 12:00:00 UTC') do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + calculated_at: 48.hours.ago + ) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal 50.0, body['submitted_percentage'] + assert_equal false, body['is_stale'] + end + end + + test 'does not serve a snapshot created before the target grade changed' do + travel_to Time.zone.parse('2026-08-10 12:00:00 UTC') do + create_snapshot( + target_grade: 2, + submitted_percentage: 60, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + calculated_at: 1.hour.ago + ) + + @project.update!(target_grade: 2) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal 2, body['target_grade'] + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_nil body['last_updated_at'] + assert body['unavailable_message'].present? + end + end + + test 'records when a project target grade changes' do + project = create(:project) + original_timestamp = project.target_grade_changed_at + + travel 1.minute + project.update!(target_grade: project.target_grade + 1) + + assert_operator( + project.reload.target_grade_changed_at, + :>, + original_timestamp + ) + end + + test 'does not change the grade timestamp for an unrelated update' do + project = create(:project) + original_timestamp = project.target_grade_changed_at + + travel 1.minute + project.update!(started: !project.started) + + assert_equal( + original_timestamp, + project.reload.target_grade_changed_at + ) + end + + test 'fails closed when required PPI configuration is missing' do + create_snapshot( + submitted_percentage: 50, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + ) + ENV.delete('DF_PPI_MINIMUM_COHORT_SIZE') + + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + assert_private_no_store + end + + test 'serves a fresh snapshot calculated after the target grade changed' do + travel_to Time.zone.parse('2026-08-10 12:00:00 UTC') do + @project.update!(target_grade: 2) + + travel 1.minute + + create_snapshot( + target_grade: 2, + submitted_percentage: 61, + cohort_size: PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + calculated_at: Time.zone.now + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 60.0, last_response_body['submitted_percentage'] + end + end + + private + + def endpoint(project: @project, task_definition: @task_definition) + "/api/projects/#{project.id}/task_def_id/" \ + "#{task_definition.id}/peer_progress" + end + + def request_as(user, path = endpoint) + clear_auth_header + add_auth_header_for(user: user) + get path + end + + def create_snapshot( + submitted_percentage:, + cohort_size:, + calculated_at: Time.zone.now, + target_grade: @project.target_grade, + status_counts: :default, + submitted_count: :default + ) + @project.update!(updated_at: calculated_at - 1.second) if + @project.updated_at > calculated_at + + peer_status_counts = if status_counts == :default + empty_status_counts.merge( + 'not_started' => cohort_size + ) + else + status_counts + end + stored_status_counts = peer_status_counts&.dup + if stored_status_counts + stored_status_counts['not_started'] += 1 + end + + peer_submitted_count = if submitted_count == :default + if submitted_percentage.nil? + nil + else + ((submitted_percentage * cohort_size) / 100.0).round + end + else + submitted_count + end + stored_submitted_count = peer_submitted_count + stored_percentage = submitted_percentage + unless stored_submitted_count.nil? + stored_percentage = ((stored_submitted_count * 100.0) / + (cohort_size + 1)).round(2) + end + + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: target_grade, + submitted_percentage: stored_percentage, + submitted_count: stored_submitted_count, + cohort_size: cohort_size + 1, + status_counts: stored_status_counts, + calculated_at: calculated_at + ) + end + + def empty_status_counts + PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 0 } + end + + def safe_status_counts + empty_status_counts.merge( + 'not_started' => 5, + 'working_on_it' => 5, + 'ready_for_feedback' => 4, + 'fix_and_resubmit' => 3, + 'redo' => 3, + 'complete' => 3, + 'fail' => 2 + ) + end + + def quantised_count_groups(cohort_size) + bucket_size = PeerProgressApi::PERCENTAGE_BUCKET_SIZE + + (0..cohort_size).group_by do |submitted_count| + exact_percentage = ((submitted_count * 100.0) / cohort_size).round(2) + ((exact_percentage / bucket_size).round * bucket_size).to_f + end + end + + def distribution_percentage(body, status) + body.fetch('status_distribution').find do |entry| + entry.fetch('status') == status + end.fetch('percentage') + end + + def assert_peer_progress_not_found + assert_equal 404, last_response.status + + body = last_response_body + + assert_json_limit_keys_to_exactly %w[error], body + + assert_equal( + PeerProgressApi::NOT_FOUND_MESSAGE, + body['error'] + ) + + assert_private_no_store + end + + def restore_env(name, value) + if value.nil? + ENV.delete(name) + else + ENV[name] = value + end + end + + def assert_private_no_store + cache_control = last_response.headers.fetch('Cache-Control', '') + + assert_includes cache_control, 'private' + assert_includes cache_control, 'no-store' + end + + def assert_peer_progress_response_contract(body) + assert_json_limit_keys_to_exactly RESPONSE_KEYS, body + + assert_kind_of Integer, body['task_definition_id'] + assert_kind_of Integer, body['unit_id'] + + assert( + body['target_grade'].nil? || + body['target_grade'].is_a?(Integer), + 'target_grade must be an integer or null' + ) + + assert( + body['submitted_percentage'].nil? || + body['submitted_percentage'].is_a?(Numeric), + 'submitted_percentage must be numeric or null' + ) + + unless body['submitted_percentage'].nil? + assert_operator body['submitted_percentage'], :>=, 0.0 + assert_operator body['submitted_percentage'], :<=, 100.0 + end + + assert( + body['completed_percentage'].nil? || + body['completed_percentage'].is_a?(Numeric), + 'completed_percentage must be numeric or null' + ) + + unless body['completed_percentage'].nil? + assert_operator body['completed_percentage'], :>=, 0.0 + assert_operator body['completed_percentage'], :<=, 100.0 + end + + if body['status_distribution'].nil? + assert_equal false, body['distribution_available'] + else + assert_equal true, body['distribution_available'] + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + body['status_distribution'].pluck('status') + + body['status_distribution'].each do |entry| + assert_json_limit_keys_to_exactly %w[status percentage], entry + assert_kind_of String, entry['status'] + assert_kind_of Numeric, entry['percentage'] + assert_operator entry['percentage'], :>=, 0.0 + assert_operator entry['percentage'], :<=, 100.0 + assert_equal 0.0, + entry['percentage'] % + PeerProgressApi::PERCENTAGE_BUCKET_SIZE + end + end + assert( + body['distribution_unavailable_reason'].nil? || + body['distribution_unavailable_reason'].is_a?(String), + 'distribution_unavailable_reason must be a string or null' + ) + + %w[ + is_suppressed + is_stale + is_feature_enabled + is_user_enabled + distribution_available + ].each do |key| + assert_includes( + [true, false], + body.fetch(key), + "#{key} must be a boolean" + ) + end + + assert( + body['unavailable_reason'].nil? || + body['unavailable_reason'].is_a?(String), + 'unavailable_reason must be a string or null' + ) + + unless body['last_updated_at'].nil? + parsed_timestamp = nil + + assert_nothing_raised do + parsed_timestamp = Time.iso8601(body['last_updated_at']) + end + + assert_equal( + 0, + parsed_timestamp.utc_offset, + 'last_updated_at must use UTC' + ) + end + + assert_kind_of String, body['unavailable_message'] + assert_empty FORBIDDEN_KEYS & body.keys + assert_private_no_store + end +end diff --git a/test/api/projects_api_test.rb b/test/api/projects_api_test.rb index 30407838e6..9f4d877466 100644 --- a/test/api/projects_api_test.rb +++ b/test/api/projects_api_test.rb @@ -62,13 +62,244 @@ def test_projects_returns_correct_data assert_json_limit_keys_to_exactly keys, data - assert_json_matches_model(project, data, %w(campus_id target_grade campus_id)) - assert_json_matches_model(project.unit, data['unit'], %w(id code name active)) + assert_json_matches_model(project, data, %w[campus_id target_grade campus_id]) + assert_json_matches_model(project.unit, data['unit'], %w[id code name active]) assert_json_matches_model project, data, key_test end end + def test_projects_with_task_definitions_uses_student_safe_serialization + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + allow_flexible_dates: true + ) + common_start_date = unit.start_date + 1.week + later_task = FactoryBot.create( + :task_definition, + unit: unit, + abbreviation: 'CROSS-Z', + start_date: common_start_date, + plagiarism_report_url: 'https://staff.invalid/report', + plagiarism_warn_pct: 99, + tii_group_id: 'staff-only-group', + similarity_language: 'staff-only-language', + use_resources_for_jplag_base_code: true, + lock_assessments_to_tutorial_stream: true, + upload_requirements: [ + { + 'key' => 'file0', + 'name' => 'Student report', + 'type' => 'document', + 'tii_check' => true, + 'tii_pct' => 35 + } + ] + ) + earlier_task = FactoryBot.create( + :task_definition, + unit: unit, + abbreviation: 'CROSS-A', + start_date: common_start_date + ) + grade_due_date = FactoryBot.create( + :task_definition_grade_due_date, + task_definition: later_task, + target_grade: 1, + target_due_date: later_task.target_date + 2.days, + start_date: later_task.start_date + 1.day + ) + student = FactoryBot.create(:user, :student) + unit.enrol_student(student, unit.tutorials.first.campus) + + add_auth_header_for(user: student) + + get '/api/projects' + assert_equal 200, last_response.status, last_response_body + assert_not last_response_body.first.key?('tasks') + assert_not last_response_body.first.fetch('unit').key?('task_definitions') + + get '/api/projects?include_task_definitions=true' + assert_equal 200, last_response.status, last_response_body + + project_data = last_response_body.first + assert project_data.key?('tasks') + unit_data = project_data.fetch('unit') + assert_equal true, unit_data.fetch('allow_flexible_dates') + + task_definitions = unit_data.fetch('task_definitions') + assert_equal [earlier_task.id, later_task.id], task_definitions.pluck('id') + + task_definitions.each do |task_definition| + %w[id abbreviation name description weighting target_grade upload_requirements].each do |key| + assert task_definition.key?(key), "Expected student-safe task definition to include #{key}" + end + + %w[ + plagiarism_report_url plagiarism_warn_pct tii_group_id similarity_language + overseer_image_id use_resources_for_jplag_base_code + lock_assessments_to_tutorial_stream restrict_status_updates created_at updated_at + ].each do |key| + assert_not task_definition.key?(key), "Student response exposed staff-only field #{key}" + end + end + + student_requirements = task_definitions.find do |task_definition| + task_definition['id'] == later_task.id + end.fetch('upload_requirements') + assert_equal( + [{ 'key' => 'file0', 'name' => 'Student report', 'type' => 'document' }], + student_requirements + ) + + student_later_task = task_definitions.find do |task_definition| + task_definition['id'] == later_task.id + end + grade_due_dates = student_later_task.fetch('grade_due_dates') + assert_equal 1, grade_due_dates.length + assert_equal grade_due_date.target_grade, grade_due_dates.first.fetch('target_grade') + assert_equal grade_due_date.target_due_date.to_date, + Date.parse(grade_due_dates.first.fetch('target_due_date')) + assert_equal grade_due_date.start_date.to_date, + Date.parse(grade_due_dates.first.fetch('start_date')) + end + + def test_projects_with_task_definitions_exposes_privacy_safe_feedback_state + project = FactoryBot.create(:project) + unit = project.unit + task_definition = unit.task_definitions.first + task = project.task_for_task_definition(task_definition) + student = project.student + tutor = unit.main_convenor_user + + task.update!(task_status: TaskStatus.ready_for_feedback) + task.add_status_comment(student, TaskStatus.ready_for_feedback) + + add_auth_header_for(user: student) + + get '/api/projects?include_task_definitions=true' + assert_equal 200, last_response.status, last_response_body + + task_data = lambda do + last_response_body + .find { |data| data['id'] == project.id } + .fetch('tasks') + .find { |data| data['id'] == task.id } + end + + assert_equal false, task_data.call.fetch('has_feedback') + + task.add_text_comment(student, 'Student follow-up') + task.add_text_comment(tutor, '**Automated Message:** Automated feedback') + + get '/api/projects?include_task_definitions=true' + assert_equal 200, last_response.status, last_response_body + assert_equal false, task_data.call.fetch('has_feedback') + + task.add_text_comment(tutor, 'Manual tutor feedback') + + get '/api/projects?include_task_definitions=true' + assert_equal 200, last_response.status, last_response_body + + response_task = task_data.call + assert_equal true, response_task.fetch('has_feedback') + + %w[ + feedback feedback_text marker_notes feedback_author + last_feedback_at has_unread_feedback + ].each do |key| + assert_not response_task.key?(key), "Student response exposed #{key}" + end + + assert_not_includes last_response.body, 'Manual tutor feedback' + assert_not_includes last_response.body, '**Automated Message:** Automated feedback' + end + + def test_projects_feedback_state_is_scoped_to_authenticated_student + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 1, + tutorials: 1 + ) + + student = FactoryBot.create(:user, :student) + other_student = FactoryBot.create(:user, :student) + + project = unit.enrol_student(student, unit.tutorials.first.campus) + other_project = unit.enrol_student(other_student, unit.tutorials.first.campus) + + task_definition = unit.task_definitions.first + project.task_for_task_definition(task_definition) + other_task = other_project.task_for_task_definition(task_definition) + + other_task.update!(task_status: TaskStatus.ready_for_feedback) + other_task.add_status_comment(other_student, TaskStatus.ready_for_feedback) + other_task.add_text_comment(unit.main_convenor_user, 'Private feedback for other student') + + add_auth_header_for(user: student) + + get '/api/projects?include_task_definitions=true' + assert_equal 200, last_response.status, last_response_body + + returned_project_ids = last_response_body.pluck('id') + + assert_includes returned_project_ids, project.id + assert_not_includes returned_project_ids, other_project.id + assert_not_includes last_response.body, 'Private feedback for other student' + + get "/api/projects/#{other_project.id}" + + assert_equal 403, last_response.status + assert_not_includes last_response.body, 'Private feedback for other student' + end + + def test_projects_with_inactive_task_definitions_avoids_per_record_queries + student = FactoryBot.create(:user, :student) + units = 2.times.map do + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 4, + tutorials: 1, + outcome_count: 0, + active: true + ) + project = unit.enrol_student(student, unit.tutorials.first.campus) + unit.task_definitions.each do |task_definition| + project.task_for_task_definition(task_definition) + end + unit + end + units.last.update!(active: false) + add_auth_header_for(user: student) + + query_count = 0 + count_query = lambda do |_name, _started, _finished, _unique_id, payload| + next if payload[:cached] || %w[SCHEMA TRANSACTION].include?(payload[:name]) + + query_count += 1 + end + + ActiveSupport::Notifications.subscribed(count_query, 'sql.active_record') do + get '/api/projects?include_inactive=true&include_task_definitions=true' + end + + assert_equal 200, last_response.status, last_response_body + assert_equal 2, last_response_body.length + active_states = last_response_body.pluck('unit').pluck('active') + assert_equal [false, true], (active_states.sort_by { |active| active ? 1 : 0 }) + assert_equal 8, (last_response_body.sum { |project| project.fetch('tasks').length }) + task_definition_count = last_response_body.sum do |project| + project.fetch('unit').fetch('task_definitions').length + end + assert_equal 8, task_definition_count + assert_operator query_count, :<=, 45, + "Expected a bounded project query graph, got #{query_count} SQL queries" + end + def test_get_project_response_is_correct user = FactoryBot.create(:user, :student, enrol_in: 1) project = user.projects.first @@ -107,8 +338,8 @@ def test_projects_works_with_inactive_units project = user.projects.find(data['id']) assert project.present?, data.inspect - assert_json_matches_model(project, data, %w(campus_id target_grade campus_id)) - assert_json_matches_model(project.unit, data['unit'], %w(code id name active)) + assert_json_matches_model(project, data, %w[campus_id target_grade campus_id]) + assert_json_matches_model(project.unit, data['unit'], %w[code id name active]) end end @@ -129,7 +360,7 @@ def test_submitted_grade_cant_change_after_submission assert_equal 200, last_response.status, last_response_body assert_equal user.projects.find(project.id).submitted_grade, 2 - keys = %w(campus_id target_grade submitted_grade compile_portfolio portfolio_available uses_draft_learning_summary) + keys = %w[campus_id target_grade submitted_grade compile_portfolio portfolio_available uses_draft_learning_summary] assert_json_limit_keys_to_exactly keys, last_response_body assert_json_matches_model project, last_response_body, keys diff --git a/test/api/push_subscriptions_api_test.rb b/test/api/push_subscriptions_api_test.rb new file mode 100644 index 0000000000..e44414fd52 --- /dev/null +++ b/test/api/push_subscriptions_api_test.rb @@ -0,0 +1,157 @@ +require 'test_helper' + +# MN-F01: storing a browser's push registration. +class PushSubscriptionsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + @user = FactoryBot.create(:user, :student) + @other = FactoryBot.create(:user, :student) + end + + def test_a_user_can_register_a_browser + params = FactoryBot.attributes_for(:push_subscription) + + add_auth_header_for(user: @user) + + assert_difference 'PushSubscription.count', 1 do + post '/api/push_subscriptions', params + end + + assert_equal 201, last_response.status + + subscription = PushSubscription.last + + assert_equal @user, subscription.user + assert_equal params[:endpoint], subscription.endpoint + end + + def test_the_response_does_not_leak_the_browser_keys + params = FactoryBot.attributes_for(:push_subscription) + + add_auth_header_for(user: @user) + post '/api/push_subscriptions', params + + json = JSON.parse(last_response.body) + + assert_equal params[:endpoint], json['endpoint'] + assert_not json.key?('p256dh'), 'the browser public key must not be sent back' + assert_not json.key?('auth'), 'the browser auth secret must not be sent back' + end + + # Same params both times, so the endpoint has to be built once and reused. The + # factory sequences it, so calling the factory twice would be a different + # browser and this would test nothing. + def test_registering_the_same_browser_twice_updates_instead_of_duplicating + params = FactoryBot.attributes_for(:push_subscription) + + add_auth_header_for(user: @user) + post '/api/push_subscriptions', params + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', params.merge(p256dh: 'BRotatedPublicKey') + end + + assert_equal 'BRotatedPublicKey', PushSubscription.last.p256dh + end + + # Shared machine. The endpoint belongs to the browser, so the registration has + # to move to whoever signed in last rather than blowing up on the unique index. + def test_registering_a_browser_another_user_had_moves_it_across + subscription = FactoryBot.create(:push_subscription, user: @other) + + add_auth_header_for(user: @user) + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', subscription.slice(:endpoint, :p256dh, :auth) + end + + assert_equal @user, subscription.reload.user + assert_empty @other.push_subscriptions.reload + end + + def test_a_user_only_sees_their_own_registrations + mine = FactoryBot.create(:push_subscription, user: @user) + FactoryBot.create(:push_subscription, user: @other) + + add_auth_header_for(user: @user) + get '/api/push_subscriptions' + + assert_equal 200, last_response.status + + json = JSON.parse(last_response.body) + + assert_equal 1, json.length + assert_equal mine.endpoint, json.first['endpoint'] + end + + def test_a_user_can_remove_their_own_registration + subscription = FactoryBot.create(:push_subscription, user: @user) + + add_auth_header_for(user: @user) + + assert_difference 'PushSubscription.count', -1 do + delete '/api/push_subscriptions', endpoint: subscription.endpoint + end + + assert_equal 200, last_response.status + end + + def test_a_user_cannot_remove_someone_elses_registration + subscription = FactoryBot.create(:push_subscription, user: @other) + + add_auth_header_for(user: @user) + + assert_no_difference 'PushSubscription.count' do + delete '/api/push_subscriptions', endpoint: subscription.endpoint + end + + assert_equal 404, last_response.status + end + + def test_an_unauthenticated_request_is_rejected + clear_auth_header + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', FactoryBot.attributes_for(:push_subscription) + end + + assert_equal 419, last_response.status + end + + def test_a_registration_missing_the_browser_keys_is_rejected + add_auth_header_for(user: @user) + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', FactoryBot.attributes_for(:push_subscription).except(:p256dh, :auth) + end + + assert_equal 400, last_response.status + end + + # Firefox and Safari endpoints run past the 255 characters a default string + # column would give us. If this ever fails the migration has regressed. + def test_a_long_endpoint_is_stored_whole + long_endpoint = "https://updates.push.services.mozilla.com/wpush/v2/#{'a' * 300}" + + add_auth_header_for(user: @user) + post '/api/push_subscriptions', FactoryBot.attributes_for(:push_subscription, endpoint: long_endpoint) + + assert_equal 201, last_response.status + assert_equal long_endpoint, PushSubscription.last.endpoint + end + + def test_deleting_the_user_deletes_their_registrations + FactoryBot.create(:push_subscription, user: @user) + + assert_difference 'PushSubscription.count', -1 do + @user.destroy! + end + end +end diff --git a/test/api/settings_push_test.rb b/test/api/settings_push_test.rb new file mode 100644 index 0000000000..7a37f587ef --- /dev/null +++ b/test/api/settings_push_test.rb @@ -0,0 +1,97 @@ +require 'test_helper' + +# MN-C01: the authenticated front end reads the VAPID public key from +# /api/settings so it is configured in one place instead of being copied into +# the web repo and going stale the first time the keys are rotated. +# +# The endpoint is intentionally authenticated because it also reports protected +# feature flags. The anonymous case at the bottom pins down that neither VAPID +# key can leak when no credentials are supplied. +# +# Separate from settings_test.rb on purpose. That file predates rubocop's style +# rules and already carries 17 offenses; adding to it would either add more or +# mean reformatting a file this ticket has no business touching. +class SettingsPushTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + clear_auth_header + add_auth_header_for + end + + teardown do + clear_auth_header + end + + # Restores whatever was there rather than deleting. The development container + # really does have these set, so a test that assumed they were absent would + # pass in CI and fail on a developer's machine. + def with_env(values) + previous = values.keys.index_with { |key| ENV.fetch(key, nil) } + values.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + yield + ensure + previous.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + + def with_vapid_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => 'BTestPublicKey', 'DOUBTFIRE_VAPID_PRIVATE_KEY' => 'BTestPrivateKey' }, &) + end + + def without_vapid_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => nil, 'DOUBTFIRE_VAPID_PRIVATE_KEY' => nil }, &) + end + + def test_the_public_key_is_published_when_push_is_configured + with_vapid_keys do + get '/api/settings' + + assert_equal 200, last_response.status + assert_equal true, last_response_body['pushEnabled'] + assert_equal 'BTestPublicKey', last_response_body['vapidPublicKey'] + end + end + + # Without keys the client must not offer the opt-in. Subscribing would fail in + # the browser with nothing on screen to explain why. + def test_push_is_reported_unavailable_without_keys + without_vapid_keys do + get '/api/settings' + + assert_equal 200, last_response.status + assert_equal false, last_response_body['pushEnabled'] + assert_nil last_response_body['vapidPublicKey'] + end + end + + # The browser needs the public key after sign-in. The private key must never + # be included in the settings response. + def test_the_private_key_is_never_published + with_vapid_keys do + get '/api/settings' + + assert_not_includes last_response.body, 'BTestPrivateKey' + assert_not_includes last_response_body.keys, 'vapidPrivateKey' + end + end + + def test_push_settings_are_not_published_to_an_anonymous_caller + clear_auth_header + + with_vapid_keys do + get '/api/settings' + + assert_equal 419, last_response.status + assert_not_includes last_response.body, 'BTestPublicKey' + assert_not_includes last_response.body, 'BTestPrivateKey' + assert_not_includes last_response_body.keys, 'vapidPublicKey' + assert_not_includes last_response_body.keys, 'vapidPrivateKey' + end + end +end diff --git a/test/api/settings_test.rb b/test/api/settings_test.rb index 2a922d1c93..e707ead96f 100644 --- a/test/api/settings_test.rb +++ b/test/api/settings_test.rb @@ -1,48 +1,93 @@ require 'test_helper' require 'json' -class SettingTest < ActiveSupport::TestCase - include Rack::Test::Methods - include TestHelpers::AuthHelper - include TestHelpers::JsonHelper - - def app - Rails.application - end - - # 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 - end +class SettingsTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + 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( + %w[d2lEnabled overseerEnabled pushEnabled tiiEnabled vapidPublicKey].sort, + last_response_body.keys.sort + ) + 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/submission/portfolio_api_test.rb b/test/api/submission/portfolio_api_test.rb new file mode 100644 index 0000000000..9b7478f480 --- /dev/null +++ b/test/api/submission/portfolio_api_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'test_helper' + +# PR-FILE-05 – Portfolio upload size limit +# +# The portfolio upload endpoint (POST /api/submission/project/:id/portfolio) +# previously enforced no file size limit at all. This test confirms the fix: +# a part exceeding Doubtfire::Application.config.max_file_size is rejected +# with 413, and confirms the rejected file is never copied into the +# project's portfolio directory (the status code alone does not prove that). +class PortfolioApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + + def with_tempfile(extension, content = 'dummy content') + Tempfile.create(['portfolio_size_test', extension]) do |f| + f.write(content) + f.flush + yield f + end + end + + test 'rejects portfolio part exceeding the configured max_file_size and stores nothing' do + original_max = Doubtfire::Application.config.max_file_size + Doubtfire::Application.config.max_file_size = 1_024 # 1 KB + + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + + add_auth_header_for(user: project.student) + + files_before = project.portfolio_files + + with_tempfile('.py', 'x' * 2_048) do |f| + uploaded = Rack::Test::UploadedFile.new(f.path, 'text/plain', true) + post "/api/submission/project/#{project.id}/portfolio", + name: 'OversizedPart', + kind: 'code', + file0: uploaded + end + + assert_equal 413, last_response.status, + "Expected 413 for a portfolio part exceeding max_file_size, got: #{last_response.body}" + assert_match(/exceeds the \d+MB file limit/i, last_response.body) + assert_equal files_before, project.portfolio_files, + 'Rejected oversized portfolio upload must not add any file to the portfolio directory' + ensure + unit.destroy + Doubtfire::Application.config.max_file_size = original_max + end +end \ No newline at end of file diff --git a/test/api/submission_access_test.rb b/test/api/submission_access_test.rb new file mode 100644 index 0000000000..b464a86931 --- /dev/null +++ b/test/api/submission_access_test.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Access-control regression coverage for the submission_details and +# submission_files endpoints in tasks_api.rb. Neither endpoint currently +# has any dedicated test coverage on main, despite both serving another +# student's submission data/files behind a single `authorise?` check. +# +# These tests do not change any application behaviour - they only assert +# that the existing `authorise? current_user, project, :get_submission` +# guard actually blocks the object-reference paths it's meant to. +class SubmissionAccessTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def setup + @unit = FactoryBot.create(:unit, perform_submissions: true, student_count: 3, staff_count: 1) + @task_definition = @unit.task_definitions.first + @owning_project = @unit.projects.first + @other_project = @unit.projects.second + + @convenor = @unit.main_convenor_user + @tutor = FactoryBot.create(:user, :tutor) + @unit.employ_staff(@tutor, Role.tutor) + + @other_unit = FactoryBot.create(:unit, student_count: 1, staff_count: 1) + @other_unit_task_definition = @other_unit.task_definitions.first + end + + def details_endpoint(project: @owning_project, task_definition: @task_definition) + "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission_details" + end + + def files_endpoint(project: @owning_project, task_definition: @task_definition) + "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission_files" + end + + # --------------------------------------------------------------------- + # submission_details + # --------------------------------------------------------------------- + + def test_submission_details_allows_owning_student + add_auth_header_for(user: @owning_project.student) + + get details_endpoint + + assert_equal 200, last_response.status + assert last_response_body.key?('has_pdf') + assert last_response_body.key?('processing_pdf') + end + + # A student is not a unit_role, so the claimed_by_unit_role_id key + # (staff-only data about who has claimed the overflow task) should not + # appear in their response at all. + def test_submission_details_does_not_expose_claim_info_to_student + add_auth_header_for(user: @owning_project.student) + + get details_endpoint + + assert_equal 200, last_response.status + refute last_response_body.key?('claimed_by_unit_role_id') + end + + def test_submission_details_allows_unit_convenor + add_auth_header_for(user: @convenor) + + get details_endpoint + + assert_equal 200, last_response.status + end + + # Staff (anyone with a unit_role) should see the claim-tracking field, + # even if it is null (no overflow claim exists yet). + def test_submission_details_exposes_claim_info_to_convenor + add_auth_header_for(user: @convenor) + + get details_endpoint + + assert_equal 200, last_response.status + assert last_response_body.key?('claimed_by_unit_role_id') + end + + def test_submission_details_allows_unit_tutor + add_auth_header_for(user: @tutor) + + get details_endpoint + + assert_equal 200, last_response.status + end + + def test_submission_details_blocks_other_student_in_same_unit + add_auth_header_for(user: @other_project.student) + + get details_endpoint(project: @owning_project) + + assert_equal 403, last_response.status + assert_equal 'You do not have permission to read submissions for this project.', + last_response_body['error'] + end + + def test_submission_details_blocks_staff_from_a_different_unit + other_unit_staff = @other_unit.main_convenor_user + add_auth_header_for(user: other_unit_staff) + + get details_endpoint(project: @owning_project) + + assert_equal 403, last_response.status + end + + def test_submission_details_blocks_unauthenticated_request + header 'auth_token', nil + header 'username', nil + + get details_endpoint + + assert_equal 419, last_response.status + end + + def test_submission_details_rejects_task_definition_from_another_unit + add_auth_header_for(user: @owning_project.student) + + get details_endpoint(task_definition: @other_unit_task_definition) + + assert_equal 404, last_response.status + end + + # --------------------------------------------------------------------- + # submission_files + # --------------------------------------------------------------------- + + def test_submission_files_allows_owning_student + add_auth_header_for(user: @owning_project.student) + + get files_endpoint + + assert_equal 200, last_response.status + end + + def test_submission_files_blocks_other_student_in_same_unit + add_auth_header_for(user: @other_project.student) + + get files_endpoint(project: @owning_project) + + assert_equal 403, last_response.status + end + + def test_submission_files_blocks_staff_from_a_different_unit + other_unit_staff = @other_unit.main_convenor_user + add_auth_header_for(user: other_unit_staff) + + get files_endpoint(project: @owning_project) + + assert_equal 403, last_response.status + end + + # Regression guard: the Content-Disposition filename is built from + # project.student.username. Confirm that only ever happens for a caller + # who has already passed the authorise? check - i.e. a cross-student + # request never reaches the point where the filename (and therefore the + # other student's username) is constructed or exposed in the response. + def test_submission_files_does_not_leak_owning_students_username_to_blocked_caller + add_auth_header_for(user: @other_project.student) + + get files_endpoint(project: @owning_project) + + assert_equal 403, last_response.status + refute_match(/#{@owning_project.student.username}/, last_response.headers['Content-Disposition'].to_s) + end + + def test_submission_files_blocks_unauthenticated_request + header 'auth_token', nil + header 'username', nil + + get files_endpoint + + assert_equal 419, last_response.status + end +end \ No newline at end of file diff --git a/test/api/task_grade_authorisation_test.rb b/test/api/task_grade_authorisation_test.rb new file mode 100644 index 0000000000..e8a7a03a78 --- /dev/null +++ b/test/api/task_grade_authorisation_test.rb @@ -0,0 +1,144 @@ +require 'test_helper' + +# +# Tests that writing the grade of a task through the task update endpoint +# requires the assessment permission, and that the ordinary student +# submission path through the same endpoint is unaffected. +# +class TaskGradeAuthorisationTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + # Creates a unit with one student and a single graded task definition that + # needs no uploaded documents. + def create_unit_with_graded_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Graded task', + description: 'Graded task', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: 'GradedTask', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: true, + max_quality_pts: 0 + }) + + [unit, td] + end + + # The unit factory only ever employs convenors, so a tutor has to be added + # explicitly for the tutor case to be a tutor rather than a second convenor. + def employ_tutor(unit) + tutor = FactoryBot.create(:user, :tutor) + unit.employ_staff(tutor, Role.tutor) + tutor + end + + def test_student_cannot_set_grade_on_own_task + unit, td = create_unit_with_graded_task + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + add_auth_header_for(user: project.student) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { grade: 3 } + + assert_equal 403, last_response.status, last_response_body + assert_equal 'You are not permitted to assess this task', last_response_body['error'] + + task.reload + assert_nil task.grade + + unit.destroy + end + + def test_tutor_can_set_grade + unit, td = create_unit_with_graded_task + project = unit.active_projects.first + task = project.task_for_task_definition(td) + tutor = employ_tutor(unit) + + assert_equal Role.tutor, tutor.role + assert_equal :tutor, project.user_role(tutor) + + add_auth_header_for(user: tutor) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { grade: 3 } + + assert_equal 200, last_response.status, last_response_body + + task.reload + assert_equal 3, task.grade + + unit.destroy + end + + def test_student_submission_without_grade_still_succeeds + unit, td = create_unit_with_graded_task + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + add_auth_header_for(user: project.student) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'ready_for_feedback' } + + assert_equal 200, last_response.status, last_response_body + + task.reload + assert_equal TaskStatus.ready_for_feedback, task.task_status + assert_nil task.grade + + unit.destroy + end + + # A refused request must not have moved the status on its way to the 403. + def test_student_grade_with_trigger_changes_nothing + unit, td = create_unit_with_graded_task + project = unit.active_projects.first + task = project.task_for_task_definition(td) + status_before = task.task_status + + add_auth_header_for(user: project.student) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'ready_for_feedback', grade: 3 } + + assert_equal 403, last_response.status, last_response_body + assert_equal 'You are not permitted to assess this task', last_response_body['error'] + + task.reload + assert_nil task.grade + assert_equal status_before, task.task_status + assert_equal 0, task.task_submissions.count + + unit.destroy + end + + def test_convenor_can_set_grade + unit, td = create_unit_with_graded_task + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + add_auth_header_for(user: unit.main_convenor_user) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { grade: 2 } + + assert_equal 200, last_response.status, last_response_body + + task.reload + assert_equal 2, task.grade + + unit.destroy + end +end diff --git a/test/api/task_prioritization_api_test.rb b/test/api/task_prioritization_api_test.rb new file mode 100644 index 0000000000..52bcaede71 --- /dev/null +++ b/test/api/task_prioritization_api_test.rb @@ -0,0 +1,484 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TaskPrioritizationApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + setup do + clear_auth_header + @today = Time.zone.parse('2026-08-24 10:00:00 UTC') + end + + teardown do + clear_auth_header + end + + test 'requires authentication' do + get endpoint + + assert_equal 419, last_response.status + end + + test 'recommends assigned definitions even before task rows exist' do + travel_to @today do + unit = create_unit + later_definition = create_task_definition(unit, name: 'Later task', target_date: 12.days.from_now) + urgent_definition = create_task_definition(unit, name: 'Urgent task', target_date: 2.days.from_now) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 0) + + assert_empty project.tasks + + assert_no_difference 'Task.count' do + request_as(student) + end + + assert_equal 200, last_response.status, last_response.body + body = last_response_body + assert_equal [urgent_definition.id, later_definition.id], body['data'].pluck('task_definition_id') + assert(body['data'].all? { |recommendation| recommendation['task_id'].nil? }) + assert_equal %w[ + task_id + task_definition_id + task_name + project_id + unit_id + priority_score + ], body['data'].first.keys + assert_equal( + { + 'page' => 1, + 'per_page' => TaskPrioritizationApi::DEFAULT_PER_PAGE, + 'total_count' => 2, + 'total_pages' => 1 + }, + body['meta'] + ) + end + end + + test 'uses flexible grade dates for assigned definitions without task rows' do + travel_to @today do + unit = create_unit(allow_flexible_dates: true) + base_earlier_definition = create_task_definition( + unit, + name: 'Base earlier task', + target_date: 2.days.from_now + ) + base_later_definition = create_task_definition( + unit, + name: 'Base later task', + target_date: 12.days.from_now + ) + create_grade_due_date(base_earlier_definition, target_grade: 1, target_due_date: 20.days.from_now) + create_grade_due_date(base_later_definition, target_grade: 1, target_due_date: 1.day.from_now) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 1) + + assert_empty project.tasks + + request_as(student) + + assert_equal [base_later_definition.id, base_earlier_definition.id], + last_response_body['data'].pluck('task_definition_id') + assert_empty project.tasks.reload + end + end + + test 'uses personalized local due dates for materialized tasks' do + travel_to @today do + unit = create_unit(allow_flexible_dates: true) + base_earlier_definition = create_task_definition( + unit, + name: 'Base earlier task', + target_date: 1.day.from_now + ) + base_later_definition = create_task_definition( + unit, + name: 'Base later task', + target_date: 20.days.from_now + ) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 0) + base_earlier_task = project.task_for_task_definition(base_earlier_definition) + base_later_task = project.task_for_task_definition(base_later_definition) + + base_earlier_task.update!(target_due_date: 20.days.from_now) + base_later_task.update!(target_due_date: 1.day.from_now) + + request_as(student) + + assert_equal [base_later_definition.id, base_earlier_definition.id], + last_response_body['data'].pluck('task_definition_id') + assert_operator last_response_body['data'].first['priority_score'], + :>, + last_response_body['data'].last['priority_score'] + end + end + + test 'uses extension-adjusted due dates for materialized tasks' do + travel_to @today do + unit = create_unit + extended_definition = create_task_definition( + unit, + name: 'Extended task', + target_date: 1.day.from_now + ) + nearer_definition = create_task_definition( + unit, + name: 'Nearer task', + target_date: 7.days.from_now + ) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 0) + extended_task = project.task_for_task_definition(extended_definition) + project.task_for_task_definition(nearer_definition) + + extended_task.update!(extensions: 2) + + request_as(student) + + assert_equal [nearer_definition.id, extended_definition.id], + last_response_body['data'].pluck('task_definition_id') + end + end + + test 'uses task-specific deadline workload and relative size in the ranking' do + travel_to @today do + unit = create_unit + early_definition = create_task_definition( + unit, + name: 'Small early task', + target_date: 5.days.from_now, + weighting: 1 + ) + clustered_small_definition = create_task_definition( + unit, + name: 'Small clustered task', + target_date: 6.days.from_now, + weighting: 1 + ) + clustered_large_definition = create_task_definition( + unit, + name: 'Large clustered task', + target_date: 6.days.from_now, + weighting: 8 + ) + student = create(:user, :student) + enrol_student(unit, student, target_grade: 0) + + request_as(student) + + returned_ids = last_response_body['data'].pluck('task_definition_id') + assert_equal clustered_large_definition.id, returned_ids.first + assert_operator returned_ids.index(clustered_small_definition.id), :<, returned_ids.index(early_definition.id) + end + end + + test 'completed work lowers workload without inflating the remaining task size' do + travel_to @today do + unit = create_unit + remaining_definition = create_task_definition( + unit, + name: 'Remaining task', + target_date: 7.days.from_now, + weighting: 1 + ) + completed_definition = create_task_definition( + unit, + name: 'Task to complete', + target_date: 7.days.from_now, + weighting: 1 + ) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 0) + + request_as(student) + score_before_completion = score_for(last_response_body['data'], remaining_definition) + + project.task_for_task_definition(completed_definition).update!(task_status: TaskStatus.complete) + request_as(student) + score_after_completion = score_for(last_response_body['data'], remaining_definition) + + assert_operator score_after_completion, :<, score_before_completion + end + end + + test 'does not recommend a dependent until its prerequisite reaches the required status' do + travel_to @today do + unit = create_unit + prerequisite_definition = create_task_definition(unit, name: 'Prerequisite') + dependent_definition = create_task_definition(unit, name: 'Dependent') + TaskPrerequisite.create!( + task_definition: dependent_definition, + prerequisite: prerequisite_definition, + task_status_id: TaskStatus.complete.id + ) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 0) + + request_as(student) + assert_equal [prerequisite_definition.id], last_response_body['data'].pluck('task_definition_id') + + prerequisite_task = project.task_for_task_definition(prerequisite_definition) + prerequisite_task.update!(task_status: TaskStatus.ready_for_feedback) + request_as(student) + assert_empty last_response_body['data'] + + prerequisite_task.update!(task_status: TaskStatus.complete) + request_as(student) + assert_equal [dependent_definition.id], last_response_body['data'].pluck('task_definition_id') + end + end + + test 'keeps attention required blocked to match submission authorization' do + travel_to @today do + unit = create_unit + prerequisite_definition = create_task_definition(unit, name: 'Attention prerequisite') + dependent_definition = create_task_definition(unit, name: 'Attention dependent') + create_prerequisite( + dependent_definition, + prerequisite_definition, + required_status: TaskStatus.attention_required + ) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 0) + project + .task_for_task_definition(prerequisite_definition) + .update!(task_status: TaskStatus.attention_required) + + request_as(student) + + assert_not_includes last_response_body['data'].pluck('task_definition_id'), dependent_definition.id + end + end + + test 'accepts rediscuss for a discussion-level prerequisite' do + travel_to @today do + unit = create_unit + prerequisite_definition = create_task_definition(unit, name: 'Discussion prerequisite') + dependent_definition = create_task_definition(unit, name: 'Discussion dependent') + create_prerequisite( + dependent_definition, + prerequisite_definition, + required_status: TaskStatus.discuss + ) + student = create(:user, :student) + project = enrol_student(unit, student, target_grade: 0) + project + .task_for_task_definition(prerequisite_definition) + .update!(task_status: TaskStatus.rediscuss) + + request_as(student) + + assert_includes last_response_body['data'].pluck('task_definition_id'), dependent_definition.id + end + end + + test 'keeps overdue and future priority scores within the zero to one hundred contract' do + travel_to @today do + unit = create_unit + overdue_definition = create_task_definition(unit, name: 'Overdue task', target_date: 40.days.ago) + future_definition = create_task_definition(unit, name: 'Future task', target_date: 7.days.from_now) + student = create(:user, :student) + enrol_student(unit, student, target_grade: 0) + + request_as(student) + + recommendations = last_response_body['data'] + scores = recommendations.pluck('priority_score') + assert(scores.all? { |score| score.between?(0, 100) }) + assert_operator score_for(recommendations, overdue_definition), + :>, + score_for(recommendations, future_definition) + end + end + + test 'only returns eligible unfinished work owned by the authenticated student' do + travel_to @today do + active_unit = create_unit + open_definition = create_task_definition(active_unit, name: 'Open task', target_grade: 0) + higher_grade_definition = create_task_definition(active_unit, name: 'Higher grade task', target_grade: 3) + excluded_definitions = non_actionable_statuses.each_with_index.to_h do |status, index| + definition = create_task_definition(active_unit, name: "Non-actionable task #{index}", target_grade: 0) + [definition, status] + end + student = create(:user, :student) + project = enrol_student(active_unit, student, target_grade: 0) + excluded_definitions.each do |definition, status| + project.task_for_task_definition(definition).update!(task_status: status) + end + + other_student = create(:user, :student) + enrol_student(active_unit, other_student, target_grade: 3) + + inactive_unit = create_unit(active: false) + inactive_definition = create_task_definition(inactive_unit, name: 'Inactive task') + enrol_student(inactive_unit, student, target_grade: 0) + + withdrawn_unit = create_unit + withdrawn_definition = create_task_definition(withdrawn_unit, name: 'Withdrawn task') + withdrawn_project = enrol_student(withdrawn_unit, student, target_grade: 0) + withdrawn_project.update!(enrolled: false) + + request_as(student) + + returned_ids = last_response_body['data'].pluck('task_definition_id') + assert_equal [open_definition.id], returned_ids + assert_not_includes returned_ids, higher_grade_definition.id + assert_not_includes returned_ids, inactive_definition.id + assert_not_includes returned_ids, withdrawn_definition.id + excluded_definitions.each_key do |definition| + assert_not_includes returned_ids, definition.id + end + end + end + + test 'paginates every recommendation without overlap' do + travel_to @today do + unit = create_unit + definitions = 3.times.map do |index| + create_task_definition( + unit, + name: "Task #{index}", + target_date: (index + 1).days.from_now + ) + end + student = create(:user, :student) + enrol_student(unit, student, target_grade: 0) + + add_auth_header_for(user: student) + get endpoint, page: 1, per_page: 2 + first_page = last_response_body + + get endpoint, page: 2, per_page: 2 + second_page = last_response_body + + returned_ids = first_page['data'].pluck('task_definition_id') + + second_page['data'].pluck('task_definition_id') + assert_equal definitions.map(&:id).sort, returned_ids.sort + assert_equal 2, first_page['data'].length + assert_equal 1, second_page['data'].length + assert_equal( + { + 'page' => 1, + 'per_page' => 2, + 'total_count' => 3, + 'total_pages' => 2 + }, + first_page['meta'] + ) + assert_empty first_page['data'].pluck('task_definition_id') & + second_page['data'].pluck('task_definition_id') + end + end + + test 'uses project and task definition ids as deterministic tie breakers' do + travel_to @today do + unit = create_unit + definitions = 2.times.map do |index| + create_task_definition( + unit, + name: "Equal task #{index}", + target_date: 5.days.from_now, + weighting: 1 + ) + end + student = create(:user, :student) + enrol_student(unit, student, target_grade: 0) + + request_as(student) + + assert_equal definitions.map(&:id).sort, last_response_body['data'].pluck('task_definition_id') + end + end + + private + + def endpoint + '/api/tasks/recommended' + end + + def request_as(user) + add_auth_header_for(user: user) + get endpoint + end + + def non_actionable_statuses + [ + TaskStatus.complete, + TaskStatus.fail, + TaskStatus.feedback_exceeded, + TaskStatus.time_exceeded, + TaskStatus.assess_in_portfolio, + TaskStatus.ready_for_feedback + ] + end + + def create_unit(active: true, allow_flexible_dates: false) + create( + :unit, + with_students: false, + task_count: 0, + staff_count: 0, + outcome_count: 0, + active: active, + allow_flexible_dates: allow_flexible_dates, + start_date: @today - 30.days, + end_date: @today + 90.days + ) + end + + def create_task_definition( + unit, + name:, + target_date: @today + 7.days, + target_grade: 0, + weighting: 1 + ) + create( + :task_definition, + unit: unit, + name: name, + start_date: @today - 7.days, + target_date: target_date, + due_date: @today + 60.days, + target_grade: target_grade, + weighting: weighting, + outcome_count: 0 + ) + end + + def create_grade_due_date(task_definition, target_grade:, target_due_date:) + create( + :task_definition_grade_due_date, + task_definition: task_definition, + target_grade: target_grade, + target_due_date: target_due_date, + start_date: task_definition.start_date + ) + end + + def create_prerequisite(task_definition, prerequisite, required_status:) + TaskPrerequisite.create!( + task_definition: task_definition, + prerequisite: prerequisite, + task_status_id: required_status.id + ) + end + + def score_for(recommendations, task_definition) + recommendations.find do |recommendation| + recommendation['task_definition_id'] == task_definition.id + end.fetch('priority_score') + end + + def enrol_student(unit, student, target_grade:) + project = unit.enrol_student(student, unit.tutorials.first&.campus) + project.update!(target_grade: target_grade) + project + end +end diff --git a/test/api/tasks_api_test.rb b/test/api/tasks_api_test.rb index 016f789e7f..96734558a8 100644 --- a/test/api/tasks_api_test.rb +++ b/test/api/tasks_api_test.rb @@ -832,14 +832,231 @@ def test_requires_discussion_blocks_complete_until_discussed_comment_added assert_equal TaskStatus.complete, task.task_status end + # discussed:true marks a task as discussed in class; discussed:false must unmark + # it by removing every marker, including legacy duplicates separated by an + # ordinary feedback comment (DOM-07). + def test_discussed_false_removes_all_discussed_comments + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Discussed toggle task', + description: 'Task used to toggle the discussed mark', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: 'DiscussToggleTask', + restrict_status_updates: false, + requires_discussion: true, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + + project = unit.active_projects.first + task = project.task_for_task_definition(td) + tutor = unit.tutors.first + + add_auth_header_for(user: tutor) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { discussed: true } + assert_equal 200, last_response.status + task.reload + assert task.has_discussed_in_class_comment?, 'discussed:true should mark the task as discussed' + assert_equal 1, task.comments.where(content_type: 'discussed_in_class').count + + task.add_text_comment(tutor, 'Feedback between legacy discussed markers') + + # add_discussed_comment now treats the marker as a boolean and will not + # create another one just because feedback was added after it. + task.add_discussed_comment(tutor) + assert_equal 1, task.comments.where(content_type: 'discussed_in_class').count + + # Reproduce legacy data written before duplicate prevention was added. + duplicate = TaskDiscussedComment.create!( + task: task, + user: tutor, + recipient: project.student, + comment: 'Discussed in class' + ) + duplicate_receipt_ids = duplicate.comments_read_receipts.ids + assert_equal 2, task.comments.where(content_type: 'discussed_in_class').count + assert_not_empty duplicate_receipt_ids + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { discussed: false } + assert_equal 200, last_response.status + task.reload + assert_not task.has_discussed_in_class_comment?, 'discussed:false should unmark the task, not add another comment' + assert_equal 0, task.comments.where(content_type: 'discussed_in_class').count + assert_empty CommentsReadReceipts.where(id: duplicate_receipt_ids), 'destroy callbacks must remove marker read receipts' + + unit.destroy + end + + # A completed task in a unit that requires discussion cannot have its discussed + # mark removed, since that would leave it complete without the evidence the + # model requires (DOM-07). + def test_discussed_false_rejected_when_the_task_is_complete + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Discussed complete guard task', + description: 'Task used to guard unmarking after complete', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: 'DiscussGuardTask', + restrict_status_updates: false, + requires_discussion: true, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + + project = unit.active_projects.first + task = project.task_for_task_definition(td) + tutor = unit.tutors.first + + add_auth_header_for(user: tutor) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { discussed: true } + assert_equal 200, last_response.status + task.add_text_comment(tutor, 'Manual tutor feedback') + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete' } + assert_equal 200, last_response.status + task.reload + assert_equal TaskStatus.complete, task.task_status + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { discussed: false } + assert_equal 403, last_response.status + task.reload + assert task.has_discussed_in_class_comment?, 'the discussed comment must survive a refused unmark' + assert_equal TaskStatus.complete, task.task_status + + unit.destroy + end + + # A helper for the refused-transition tests below. An ordinary task definition, + # nothing about it restricted, so the only reason a transition can be refused is + # the one the test is asking about. + def ordinary_task_definition_for(unit, restrict: false) + TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: "Refusal reporting task #{restrict}", + description: 'Task used to check refused transitions are reported', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: "RefuseTask#{restrict ? 'R' : 'O'}", + restrict_status_updates: restrict, + requires_discussion: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + end + + # A student asking for a staff status is refused inside trigger_transition, which + # returns nil and adds no error. That used to reach the 200 at the end of the + # handler, so the client showed the change as accepted. + def test_refused_transition_to_a_staff_status_returns_forbidden + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = ordinary_task_definition_for(unit) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + status_before = task.task_status + + add_auth_header_for(user: project.student) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete' } + + assert_equal 403, last_response.status, last_response.body + assert_equal 'This status change is not allowed for this task.', last_response_body['error'] + + task.reload + assert_equal status_before, task.task_status + end + + # An unrecognised trigger string falls through the case statement and is refused + # the same silent way, whoever sends it. + def test_unrecognised_trigger_returns_forbidden + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = ordinary_task_definition_for(unit) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + status_before = task.task_status + + add_auth_header_for(user: unit.tutors.first) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'competed' } + + assert_equal 403, last_response.status, last_response.body + assert_equal 'This status change is not allowed for this task.', last_response_body['error'] + + task.reload + assert_equal status_before, task.task_status + end + + # The regression check. This change makes a permissive endpoint strict, so the + # failure mode is that ordinary marking stops working. + def test_allowed_transitions_still_return_success + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = ordinary_task_definition_for(unit) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + add_auth_header_for(user: project.student) + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'working_on_it' } + assert_equal 200, last_response.status, last_response.body + task.reload + assert_equal TaskStatus.working_on_it, task.task_status + + add_auth_header_for(user: unit.tutors.first) + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'discuss' } + assert_equal 200, last_response.status, last_response.body + task.reload + assert_equal TaskStatus.discuss, task.task_status + end + + # The restricted message is the one sentence in this endpoint that tells a + # student something they can act on, so it has to survive ahead of the generic + # one. Nothing in the test tree protected it before. + def test_restricted_task_keeps_its_own_refusal_message + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = ordinary_task_definition_for(unit, restrict: true) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + # Put the task at a staff assigned status first, which is the condition the + # restricted guard actually tests. + add_auth_header_for(user: unit.tutors.first) + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'discuss' } + assert_equal 200, last_response.status, last_response.body + + add_auth_header_for(user: project.student) + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'working_on_it' } + + assert_equal 403, last_response.status, last_response.body + assert_equal 'This task can only be updated by your tutor.', last_response_body['error'] + + task.reload + assert_equal TaskStatus.discuss, task.task_status + end + def test_require_comment_for_feedback_submission_assess_in_portfolio unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) td1 = unit.task_definitions.first project = unit.active_projects.first - task = project.task_for_task_definition(td1) - - td1.update( + td1.update!( upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], target_grade: 0, # Pass start_date: Time.zone.now - 2.weeks, @@ -847,6 +1064,8 @@ def test_require_comment_for_feedback_submission_assess_in_portfolio assess_in_portfolio_only: false ) + task = project.task_for_task_definition(td1) + add_auth_header_for(user: project.user) # Use a direct submit here so the test can focus on the comment requirement. @@ -889,6 +1108,100 @@ def test_require_comment_for_feedback_submission_assess_in_portfolio assert_equal comment, text_comment.comment end + # A task definition with one upload requirement, used by the finalised-task + # upload tests below. + def uploadable_task_definition_for(unit) + td = unit.task_definitions.first + td.update!( + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + assess_in_portfolio_only: false, + restrict_status_updates: false + ) + td + end + + # A signed off task used to keep accepting uploads. The upload rewrote + # submission_date and file_uploaded_at and deleted the assessed pdf, and only + # the status transition was skipped, so the damage was silent. + def test_student_cannot_upload_to_a_complete_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + td = uploadable_task_definition_for(unit) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + task.update!(task_status: TaskStatus.complete, submission_date: Time.zone.now - 1.day) + submission_date_before = task.reload.submission_date + + add_auth_header_for(user: project.user) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", + with_file('test_files/submissions/program.cs', 'application/json', { trigger: 'ready_for_feedback' }) + + assert_equal 403, last_response.status, last_response.body + assert_equal 'This task is closed for new submissions.', last_response_body['error'] + + task.reload + assert_equal TaskStatus.complete, task.task_status + assert_equal submission_date_before.to_i, task.submission_date.to_i + end + + # feedback_exceeded is the state students are otherwise barred from leaving, so + # it is the one where a silent upload is most misleading. + def test_student_cannot_upload_to_a_feedback_exceeded_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + td = uploadable_task_definition_for(unit) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + task.update!(task_status: TaskStatus.feedback_exceeded, submission_date: Time.zone.now - 1.day) + submission_date_before = task.reload.submission_date + + add_auth_header_for(user: project.user) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", + with_file('test_files/submissions/program.cs', 'application/json', { trigger: 'ready_for_feedback' }) + + assert_equal 403, last_response.status, last_response.body + + task.reload + assert_equal TaskStatus.feedback_exceeded, task.task_status + assert_equal submission_date_before.to_i, task.submission_date.to_i + end + + # Staff go through on purpose. A tutor uploads on a student's behalf when a file + # is corrupt or was submitted against the wrong task. + def test_staff_can_still_upload_to_a_complete_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + td = uploadable_task_definition_for(unit) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + task.update!(task_status: TaskStatus.complete) + + add_auth_header_for(user: unit.main_convenor_user) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", + with_file('test_files/submissions/program.cs', 'application/json', { trigger: 'ready_for_feedback' }) + + assert_equal 201, last_response.status, last_response.body + end + + # The regression check. Ordinary resubmission is untouched. + def test_student_can_still_upload_to_an_open_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + td = uploadable_task_definition_for(unit) + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + task.update!(task_status: TaskStatus.ready_for_feedback) + + add_auth_header_for(user: project.user) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", + with_file('test_files/submissions/program.cs', 'application/json', { trigger: 'ready_for_feedback' }) + + assert_equal 201, last_response.status, last_response.body + end + def test_resubmission_doesnt_change_submission_date Sidekiq::Testing.inline! do unit = FactoryBot.create( diff --git a/test/api/test_attempts_test.rb b/test/api/test_attempts_test.rb index be0c02ae5e..7742eb2fc2 100644 --- a/test/api/test_attempts_test.rb +++ b/test/api/test_attempts_test.rb @@ -495,4 +495,221 @@ def test_delete_attempt td.destroy! unit.destroy! end + + # A student may write their own scorm runtime state, but the pass or fail + # decision belongs to staff. Sending it inside the datamodel must not move it. + def test_student_cannot_pass_own_attempt_via_datamodel + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + td = scorm_task_definition(unit, 'ScormPassInjection') + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + dm = JSON.parse(attempt.cmi_datamodel) + dm["cmi.completion_status"] = "completed" + dm["cmi.success_status"] = "passed" + dm["cmi.score.scaled"] = "1" + + add_auth_header_for(user: user) + + patch "api/test_attempts/#{attempt.id}", { cmi_datamodel: dm.to_json } + assert_equal 200, last_response.status + + attempt = TestAttempt.find(attempt.id) + + # Completion is the student's own progress, so it still lands. + assert_equal true, attempt.completion_status + # The pass and the score are not, so both columns keep their defaults. The + # score is pinned to 0.0 rather than "not 1.0" so a partial score leaking + # through would fail here too. + assert_equal false, attempt.success_status + assert_equal 0.0, attempt.score_scaled + + td.destroy! + unit.destroy! + end + + # The ordinary case. Completion, resume and the interactions counter are the + # student's to write and none of them are affected by the change above. + def test_student_can_record_ordinary_progress_and_resume + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + td = scorm_task_definition(unit, 'ScormProgress') + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + dm = JSON.parse(attempt.cmi_datamodel) + dm["cmi.completion_status"] = "incomplete" + dm["cmi.interactions._count"] = "3" + + add_auth_header_for(user: user) + + patch "api/test_attempts/#{attempt.id}", { cmi_datamodel: dm.to_json } + assert_equal 200, last_response.status + + attempt = TestAttempt.find(attempt.id) + saved = JSON.parse(attempt.cmi_datamodel) + + assert_equal "resume", saved["cmi.entry"] + assert_equal "3", saved["cmi.interactions._count"] + assert_equal false, attempt.completion_status + assert_equal false, attempt.terminated + + saved["cmi.completion_status"] = "completed" + + add_auth_header_for(user: user) + + patch "api/test_attempts/#{attempt.id}", { cmi_datamodel: saved.to_json, terminated: true } + assert_equal 200, last_response.status + + attempt = TestAttempt.find(attempt.id) + + assert_equal true, attempt.completion_status + assert_equal true, attempt.terminated + + td.destroy! + unit.destroy! + end + + # The staff path is untouched. It writes success_status directly and never + # goes through the datamodel setter. + def test_tutor_can_still_override_success_status + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + td = scorm_task_definition(unit, 'ScormTutorOverride') + tutor = project.tutor_for(td) + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + dm = JSON.parse(attempt.cmi_datamodel) + dm["cmi.completion_status"] = "completed" + + add_auth_header_for(user: user) + + patch "api/test_attempts/#{attempt.id}", { cmi_datamodel: dm.to_json, terminated: true } + assert_equal 200, last_response.status + + add_auth_header_for(user: tutor) + + patch "api/test_attempts/#{attempt.id}", { success_status: true } + assert_equal 200, last_response.status + + attempt = TestAttempt.find(attempt.id) + + assert_equal true, attempt.success_status + assert_equal "passed", JSON.parse(attempt.cmi_datamodel)["cmi.success_status"] + + td.destroy! + unit.destroy! + end + + # The check that was already there on the route still stands. + def test_student_cannot_send_success_status_directly + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + td = scorm_task_definition(unit, 'ScormDirectOverride') + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + add_auth_header_for(user: user) + + patch "api/test_attempts/#{attempt.id}", { success_status: true } + assert_equal 403, last_response.status + + attempt = TestAttempt.find(attempt.id) + + assert_equal false, attempt.success_status + + td.destroy! + unit.destroy! + end + + # The other half of the change, written down so it is not read later as a + # regression. A student whose package genuinely reports a pass no longer has + # that pass recorded. The attempt reads as unsuccessful, and because nothing + # reads it as a pass the student is not blocked from trying again. Staff + # recording it is the only path to a pass. + def test_legitimate_pass_is_only_recorded_by_staff + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + td = scorm_task_definition(unit, 'ScormLegitimatePass') + tutor = project.tutor_for(td) + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + dm = JSON.parse(attempt.cmi_datamodel) + dm["cmi.completion_status"] = "completed" + dm["cmi.success_status"] = "passed" + dm["cmi.score.scaled"] = "1" + + add_auth_header_for(user: user) + + patch "api/test_attempts/#{attempt.id}", { cmi_datamodel: dm.to_json, terminated: true } + assert_equal 200, last_response.status + + attempt = TestAttempt.find(attempt.id) + + assert_equal true, attempt.completion_status + assert_equal false, attempt.success_status + assert_equal 0.0, attempt.score_scaled + + # The comment both the student and the tutor read on the task. + assert_equal "Unsuccessful", attempt.scorm_comment.comment + + # The attempt gate reads success_status, so it does not close on the student. + add_auth_header_for(user: user) + + post "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 201, last_response.status + + # And the staff override is still the way the pass gets recorded. + add_auth_header_for(user: tutor) + + patch "api/test_attempts/#{attempt.id}", { success_status: true } + assert_equal 200, last_response.status + + assert_equal true, TestAttempt.find(attempt.id).success_status + + td.destroy! + unit.destroy! + end + + # A scorm enabled task definition, with the settings the other tests in this + # file already use. Not marked private, because a private keyword here would + # silently stop minitest collecting any test method appended below it. + def scorm_task_definition(unit, abbreviation) + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: "Test attempts #{abbreviation}", + description: 'Test attempts', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now - 1.week, + due_date: Time.zone.now + 1.week, + abbreviation: abbreviation, + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0 + } + ) + td.save! + td + 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/similarity_scan_test.rb b/test/api/units/similarity_scan_test.rb new file mode 100644 index 0000000000..491db8dc93 --- /dev/null +++ b/test/api/units/similarity_scan_test.rb @@ -0,0 +1,52 @@ +require 'test_helper' + +# Covers POST /units/:id/similarity/scan, the on-demand plagiarism rescan. +class UnitsSimilarityScanApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + # A tutor is not one of the roles granted :run_similarity_scan, so the endpoint + # must refuse before it queues anything. + def test_tutor_cannot_run_similarity_scan + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + tutor = FactoryBot.create(:user, :tutor) + unit.employ_staff(tutor, Role.tutor) + + add_auth_header_for(user: tutor) + post "/api/units/#{unit.id}/similarity/scan" + + assert_equal 403, last_response.status, last_response_body + end + + # A scan recorded in the last 30 minutes puts the unit inside the cooldown, so a + # convenor's request is rate limited rather than queuing a second scan. + def test_similarity_scan_is_rate_limited_within_the_cooldown + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + unit.update!(last_plagarism_scan: Time.zone.now) + + add_auth_header_for(user: unit.main_convenor_user) + post "/api/units/#{unit.id}/similarity/scan" + + assert_equal 429, last_response.status, last_response_body + end + + # sidekiq-unique-jobs returns nil when its :reject conflict strategy refuses a + # duplicate. That is distinct from the completed-scan cooldown above: the first + # job may still be queued or running and therefore has not stamped the unit yet. + def test_similarity_scan_returns_conflict_when_duplicate_enqueue_is_rejected + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + + add_auth_header_for(user: unit.main_convenor_user) + CheckUnitSimilarityJob.stub(:perform_async, nil) do + post "/api/units/#{unit.id}/similarity/scan" + end + + assert_equal 409, last_response.status, last_response_body + assert_equal 'A similarity scan is already queued or running for this unit.', last_response_body['error'] + end +end diff --git a/test/api/units/task_definitions_api_test.rb b/test/api/units/task_definitions_api_test.rb index 00967f32b0..6239840787 100644 --- a/test/api/units/task_definitions_api_test.rb +++ b/test/api/units/task_definitions_api_test.rb @@ -1,4 +1,5 @@ require 'test_helper' +require 'minitest/mock' class TaskDefinitionsTest < ActiveSupport::TestCase include Rack::Test::Methods @@ -35,21 +36,21 @@ def test_task_definition_cud data_to_post = { task_def: { - tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation, - name: 'New Task Def', - description: 'First task def', - weighting: 4, - target_grade: 1, - group_set_id: unit.group_sets.first.id, - start_date: unit.start_date, - target_date: unit.start_date + 7.days, - due_date: unit.start_date + 21.days, - abbreviation: 'P1.1', - restrict_status_updates: false, - upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]', - plagiarism_warn_pct: 80, - is_graded: false, - max_quality_pts: 0 + tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation, + name: 'New Task Def', + description: 'First task def', + weighting: 4, + target_grade: 1, + group_set_id: unit.group_sets.first.id, + start_date: unit.start_date, + target_date: unit.start_date + 7.days, + due_date: unit.start_date + 21.days, + abbreviation: 'P1.1', + restrict_status_updates: false, + upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]', + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 } } @@ -69,21 +70,21 @@ def test_task_definition_cud data_to_put = { task_def: { - tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation, - name: 'New Task Def 1', - description: 'First task def 1', - weighting: 2, - target_grade: 2, - group_set_id: nil, - start_date: unit.start_date + 2.days, - target_date: unit.start_date + 9.days, - due_date: unit.start_date + 23.days, - abbreviation: 'P1.2', - restrict_status_updates: true, - upload_requirements: [ { "key": "file0", "name": "Other Class", "type": "document" } ].to_json, - plagiarism_warn_pct: 80, - is_graded: false, - max_quality_pts: 0 + tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation, + name: 'New Task Def 1', + description: 'First task def 1', + weighting: 2, + target_grade: 2, + group_set_id: nil, + start_date: unit.start_date + 2.days, + target_date: unit.start_date + 9.days, + due_date: unit.start_date + 23.days, + abbreviation: 'P1.2', + restrict_status_updates: true, + upload_requirements: [{ "key": "file0", "name": "Other Class", "type": "document" }].to_json, + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 } } @@ -101,6 +102,83 @@ def test_task_definition_cud assert_equal 2, td.weighting end + def new_task_definition_payload(unit) + { + task_def: { + name: 'Notification Queue Test', + description: 'Task used to test notification queue behaviour', + weighting: 1, + target_grade: 1, + start_date: unit.start_date, + target_date: unit.start_date + 7.days, + due_date: unit.start_date + 14.days, + abbreviation: "QUEUE#{SecureRandom.hex(3)}", + restrict_status_updates: false, + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 + } + } + end + + def test_task_definition_creation_enqueues_new_task_notification + unit = FactoryBot.create(:unit, task_count: 0) + enqueued_task_definition_id = nil + + enqueue = lambda do |task_definition_id| + enqueued_task_definition_id = task_definition_id + end + + NewTaskAvailableNotificationJob.stub(:perform_async, enqueue) do + add_auth_header_for(user: unit.main_convenor_user) + + post_json( + "/api/units/#{unit.id}/task_definitions", + new_task_definition_payload(unit) + ) + end + + assert_equal 201, last_response.status, last_response_body + + created_task_definition = unit.task_definitions.order(:id).last + + assert_equal( + created_task_definition.id, + enqueued_task_definition_id + ) + assert_not_nil created_task_definition.reload.new_task_notifications_from + end + + def test_task_definition_creation_succeeds_when_enqueue_fails + unit = FactoryBot.create(:unit, task_count: 0) + + enqueue_failure = lambda do |_task_definition_id| + raise StandardError, 'Redis unavailable' + end + + NewTaskAvailableNotificationJob.stub( + :perform_async, + enqueue_failure + ) do + add_auth_header_for(user: unit.main_convenor_user) + + assert_difference('TaskDefinition.count', 1) do + post_json( + "/api/units/#{unit.id}/task_definitions", + new_task_definition_payload(unit) + ) + end + end + + assert_equal 201, last_response.status, last_response_body + created_task_definition = unit.task_definitions.order(:id).last + assert_equal( + 'Notification Queue Test', + created_task_definition.name + ) + assert_not_nil created_task_definition.new_task_notifications_from + end + def test_post_invalid_file_tasksheet test_unit = FactoryBot.create(:unit, task_count: 1) test_task_definition_id = test_unit.task_definitions.first.id @@ -181,30 +259,30 @@ def test_post_task_resources ] # Save will trigger TII integration - create_tii_group_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*]). - with(tii_headers). - with(body: %r[.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*]). - to_return(status: 200, body: "", headers: {}) - - post_attachment_stub = stub_request(:post, %r[https://localhost/api/v1/groups/.*/attachments]). - with(tii_headers). - with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}"). - to_return( - status: 200, - body: TCAClient::AddGroupAttachmentResponse.new( - id: SecureRandom.uuid - ).to_json, - headers: {} - ) - - upload_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*/attachments/.*/original]). - with(tii_headers). - with(headers: {'Content-Type'=>'binary/octet-stream'}). - to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {}) - - delete_stub = stub_request(:delete, %r[https://localhost/api/v1/groups/.*/attachments/.*]). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + create_tii_group_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*]) + .with(tii_headers) + .with(body: %r[.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*]) + .to_return(status: 200, body: "", headers: {}) + + post_attachment_stub = stub_request(:post, %r[https://localhost/api/v1/groups/.*/attachments]) + .with(tii_headers) + .with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}") + .to_return( + status: 200, + body: TCAClient::AddGroupAttachmentResponse.new( + id: SecureRandom.uuid + ).to_json, + headers: {} + ) + + upload_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*/attachments/.*/original]) + .with(tii_headers) + .with(headers: { 'Content-Type' => 'binary/octet-stream' }) + .to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {}) + + delete_stub = stub_request(:delete, %r[https://localhost/api/v1/groups/.*/attachments/.*]) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) td.save! @@ -241,21 +319,21 @@ def test_post_scorm def test_submission_creates_folders unit = Unit.first td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'test_submission_creates_folders', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'test_submission_creates_folders', - restrict_status_updates: false, - upload_requirements: [ { "key" => "file0", "name" => "Shape Class", "type" => "document" } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'test_submission_creates_folders', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'test_submission_creates_folders', + restrict_status_updates: false, + upload_requirements: [{ "key" => "file0", "name" => "Shape Class", "type" => "document" }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -295,21 +373,21 @@ def test_submission_creates_folders def test_change_to_group_after_submissions unit = Unit.first td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task to switch from ind to group after submission', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'TaskSwitchIndGrp', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'document' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task to switch from ind to group after submission', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'TaskSwitchIndGrp', + restrict_status_updates: false, + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'document' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -335,7 +413,7 @@ def test_change_to_group_after_submissions # Change it to a group task - group_set = GroupSet.create!({name: 'test group set', unit: unit}) + group_set = GroupSet.create!({ name: 'test group set', unit: unit }) group_set.save! td.group_set = group_set @@ -836,8 +914,8 @@ def test_task_related_to_task_def_when_multiple_projects_tasks_and_tutorials end def test_change_draft_learning_summary_upload_requirements - unit = FactoryBot.create :unit, student_count:1, task_count:0 - upload_reqs = [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}] + unit = FactoryBot.create :unit, student_count: 1, task_count: 0 + upload_reqs = [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }] task_def = FactoryBot.create(:task_definition, unit: unit, upload_requirements: upload_reqs) # Set draft learning summary task defintion @@ -857,7 +935,7 @@ def test_change_draft_learning_summary_upload_requirements # Test change upload requirements to a non-document upload data_to_put = { task_def: { - upload_requirements: [{"key": "file0","name": "Code file","type": "code"}].to_json + upload_requirements: [{ "key": "file0", "name": "Code file", "type": "code" }].to_json } } @@ -984,4 +1062,92 @@ def test_download_student_submission_jobs end end end + + def test_due_date_update_enqueues_notification_job + unit = FactoryBot.create(:unit, task_count: 1) + task_def = unit.task_definitions.first + + previous_due_date = task_def[:due_date]&.to_date&.iso8601 + new_due_date = (task_def.due_date + 1.week).to_date + + data_to_put = { + task_def: { + due_date: new_due_date + } + } + + add_auth_header_for(user: unit.main_convenor_user) + + assert_difference( + -> { TaskDueDateChangedNotificationJob.jobs.size }, + 1 + ) do + put_json( + "/api/units/#{unit.id}/task_definitions/#{task_def.id}", + data_to_put + ) + end + + assert_equal 200, last_response.status, last_response_body + + assert_equal( + [ + task_def.id, + previous_due_date, + new_due_date.iso8601 + ], + TaskDueDateChangedNotificationJob.jobs.last['args'] + ) + end + + def test_unrelated_update_does_not_enqueue_due_date_notification_job + unit = FactoryBot.create(:unit, task_count: 1) + task_def = unit.task_definitions.first + + data_to_put = { + task_def: { + description: 'Updated without moving the due date.' + } + } + + add_auth_header_for(user: unit.main_convenor_user) + + assert_no_difference( + -> { TaskDueDateChangedNotificationJob.jobs.size } + ) do + put_json( + "/api/units/#{unit.id}/task_definitions/#{task_def.id}", + data_to_put + ) + end + + assert_equal 200, last_response.status, last_response_body + end + + def test_due_date_update_succeeds_when_enqueue_fails + unit = FactoryBot.create(:unit, task_count: 1) + task_def = unit.task_definitions.first + new_due_date = (task_def.due_date + 1.week).to_date + + data_to_put = { + task_def: { + due_date: new_due_date + } + } + + add_auth_header_for(user: unit.main_convenor_user) + + TaskDueDateChangedNotificationJob.stub( + :perform_async, + ->(*) { raise StandardError, 'Redis unavailable' } + ) do + put_json( + "/api/units/#{unit.id}/task_definitions/#{task_def.id}", + data_to_put + ) + end + + assert_equal 200, last_response.status, last_response_body + assert_equal new_due_date, task_def.reload[:due_date].to_date + end end diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index 23add5b13e..5a22933930 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -488,6 +488,74 @@ def test_put_update_unit_invalid_id assert_equal 404, last_response.status end + def test_main_convenor_can_enable_peer_progress + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0 + ) + + add_auth_header_for(user: unit.main_convenor_user) + + put_json( + "/api/units/#{unit.id}", + { + unit: { + peer_progress_enabled: true + } + } + ) + + assert_equal 200, last_response.status, last_response_body + assert unit.reload.peer_progress_enabled? + assert_equal true, last_response_body['peer_progress_enabled'] + end + + def test_student_cannot_enable_peer_progress + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + tutorials: 1 + ) + + student = FactoryBot.create(:user, :student) + unit.enrol_student( + student, + unit.tutorials.first.campus + ) + + add_auth_header_for(user: student) + + put_json( + "/api/units/#{unit.id}", + { + unit: { + peer_progress_enabled: true + } + } + ) + + assert_equal 403, last_response.status + assert_not unit.reload.peer_progress_enabled? + end + + def test_unit_details_expose_peer_progress_setting_to_the_convenor + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + peer_progress_enabled: true + ) + + add_auth_header_for(user: unit.main_convenor_user) + + get "/api/units/#{unit.id}" + + assert_equal 200, last_response.status + assert_equal true, last_response_body['peer_progress_enabled'] + end + # Test can update unit start and end dates def test_put_update_unit_dates # Add username and auth_token to Header diff --git a/test/api/upload_security_test.rb b/test/api/upload_security_test.rb new file mode 100644 index 0000000000..aea94bd954 --- /dev/null +++ b/test/api/upload_security_test.rb @@ -0,0 +1,1012 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'zip' + +# FILE-S01 – Upload Authorisation and Abuse Tests +# +# Exercises the FILE-S01 controls that can be verified deterministically in the +# API test environment. The threat model and findings disposition in +# docs/security/ describe the covered controls and the deliberately open gaps. +# +# 1. Direct API upload without using the frontend +# 2. Access to another student's or project's attachment +# 3. Misleading extensions and mismatched MIME types +# 4. File-signature mismatch where the policy uses signature checks +# 5. Empty, oversized, malformed, and unsupported files +# 6. Path traversal, control characters, and unusual Unicode filenames +# 7. Download headers and active-content rendering behaviour +# 8. Macro-enabled documents, archives, encrypted files +# 9. Sequential duplicate upload and archive resource-exhaustion controls +# 10. Cleanup before staging for rejected uploads + +class UploadSecurityTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::TestFileHelper + include TestHelpers::AuthHelper + + # ───────────────────────────────────────────────────────────────────────────── + # Helpers + # ───────────────────────────────────────────────────────────────────────────── + + # Build a minimal TaskDefinition with configurable upload requirements. + def create_task_definition(unit:, upload_requirements: [{ 'key' => 'file0', 'name' => 'Submission', 'type' => 'code' }]) + TaskDefinition.create!( + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Security Test Task', + description: 'Security Test Task', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: "SecTask#{SecureRandom.hex(4)}", + restrict_status_updates: false, + upload_requirements: upload_requirements, + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + ) + end + + # Create a Tempfile with given content and extension, yield it, then clean up. + def with_tempfile(extension, content = 'dummy content', binary: false) + Tempfile.create(['sec_test', extension]) do |f| + f.binmode if binary + f.write(content) + f.flush + yield f + end + end + + # Post a submission to the API with an arbitrary Rack::Test::UploadedFile. + # Uses the same hash structure that scoop_files expects (file is a Hash with + # :filename, :type, :name, :tempfile keys via Rack multipart parsing). + def post_submission(project, task_def, uploaded_file, trigger: 'ready_for_feedback') + data = { trigger: trigger, file0: uploaded_file } + post "/api/projects/#{project.id}/task_def_id/#{task_def.id}/submission", data + end + + def upload_storage_entries + roots = [ + File.join(Dir.tmpdir, 'doubtfire', 'new'), + FileHelper.student_work_dir(:new, nil, false), + FileHelper.student_work_dir(:in_process, nil, false) + ] + + roots.flat_map do |root| + next [] unless Dir.exist?(root) + + Dir.glob(File.join(root, '**', '*')) + end.sort + end + + def capture_rails_logs(level: Logger::DEBUG) + output = StringIO.new + test_logger = Logger.new(output) + test_logger.level = level + original_logger = Rails.logger + Rails.logger = test_logger + + yield + output.string + ensure + Rails.logger = original_logger if defined?(original_logger) && original_logger + end + + # ───────────────────────────────────────────────────────────────────────────── + # 1. Direct API upload without using the frontend + # The backend must enforce authentication and authorisation regardless of + # whether a frontend-originated cookie/CSRF token is present. + # ───────────────────────────────────────────────────────────────────────────── + + test 'unauthenticated direct API upload is rejected with 419' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + + # No auth header – simulate a raw API call with no session at all. + with_tempfile('.py', "print('hello')") do |f| + post_submission(project, td, Rack::Test::UploadedFile.new(f.path, 'text/plain')) + end + + assert_equal 419, last_response.status, + 'Expected 419 (authentication required) for unauthenticated direct API upload' + ensure + unit.destroy + end + + test 'authenticated direct API upload succeeds for own project' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + + add_auth_header_for(user: project.student) + + with_tempfile('.py', "print('hello')") do |f| + post_submission(project, td, Rack::Test::UploadedFile.new(f.path, 'text/plain')) + end + + assert_equal 201, last_response.status, + 'Expected 201 for a valid authenticated direct API upload' + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 2. Access to another student's or project's attachment + # A student must not be able to submit on behalf of another project, nor + # download another student's submission PDF. + # ───────────────────────────────────────────────────────────────────────────── + + test 'student cannot submit to another student\'s project' do + unit = FactoryBot.create(:unit, student_count: 2, task_count: 0) + projects = unit.active_projects + project_a = projects.first + project_b = projects.second + td = create_task_definition(unit: unit) + + # Authenticate as student A but post to project B's endpoint. + add_auth_header_for(user: project_a.student) + + side_effects_before = { + tasks: Task.count, + submissions: TaskSubmission.count, + jobs: AcceptSubmissionJob.jobs.size, + storage: upload_storage_entries + } + + with_tempfile('.py', "print('owned')") do |f| + post_submission(project_b, td, Rack::Test::UploadedFile.new(f.path, 'text/plain')) + end + + assert_equal 401, last_response.status, + 'Expected the current submission API contract to return 401 for a cross-project POST' + assert_match(/not authorised to submit task/i, last_response.body) + assert_equal side_effects_before[:tasks], Task.count, + 'Rejected cross-project POST must not create a task' + assert_equal side_effects_before[:submissions], TaskSubmission.count, + 'Rejected cross-project POST must not create a submission row' + assert_equal side_effects_before[:jobs], AcceptSubmissionJob.jobs.size, + 'Rejected cross-project POST must not enqueue submission processing' + assert_equal side_effects_before[:storage], upload_storage_entries, + 'Rejected cross-project POST must not write submission files' + ensure + unit.destroy + end + + test 'student cannot download another student\'s submission PDF' do + unit = FactoryBot.create(:unit, student_count: 2, task_count: 0) + projects = unit.active_projects + project_a = projects.first + project_b = projects.second + td = create_task_definition(unit: unit) + + # Authenticate as student B and attempt to fetch project A's submission. + add_auth_header_for(user: project_b.student) + + get "/api/projects/#{project_a.id}/task_def_id/#{td.id}/submission" + + assert_equal 401, last_response.status, + 'Expected the current submission API contract to return 401 for a cross-project GET' + assert_match(/not authorised to get task/i, last_response.body) + ensure + unit.destroy + end + + test 'student cannot access another student\'s submission history' do + unit = FactoryBot.create(:unit, student_count: 2, task_count: 0) + projects = unit.active_projects + project_a = projects.first + project_b = projects.second + td = create_task_definition(unit: unit) + + add_auth_header_for(user: project_b.student) + + get "/api/projects/#{project_a.id}/task_def_id/#{td.id}/submission_histories" + + assert_equal 401, last_response.status, + 'Expected the current history API contract to return 401 for cross-project access' + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 3. Misleading extensions and mismatched MIME types + # A file whose extension says .pdf but whose content (MIME) is something + # else must be rejected by the server-side MIME sniff. + # ───────────────────────────────────────────────────────────────────────────── + + test 'rejects file with PDF extension but plain-text content' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition( + unit: unit, + upload_requirements: [{ 'key' => 'file0', 'name' => 'Report', 'type' => 'document' }] + ) + + add_auth_header_for(user: project.student) + + # Actual content is plain text, but we claim .pdf and application/pdf. + with_tempfile('.pdf', 'This is not a PDF at all') do |f| + uploaded = Rack::Test::UploadedFile.new(f.path, 'application/pdf', true) + post_submission(project, td, uploaded) + end + + assert_equal 403, last_response.status, + 'Expected MIME validation to reject PDF extension with non-PDF content' + assert_match(/invalid file MIME type/i, last_response.body) + ensure + unit.destroy + end + + test 'rejects executable disguised with .txt extension' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + + add_auth_header_for(user: project.student) + + # ELF magic bytes – a Linux executable masquerading as a text file. + elf_magic = "\x7fELF\x02\x01\x01\x00#{"\x00" * 8}" + with_tempfile('.txt', elf_magic, binary: true) do |f| + uploaded = Rack::Test::UploadedFile.new(f.path, 'text/plain', true) + post_submission(project, td, uploaded) + end + + assert_equal 403, last_response.status, + 'Expected MIME validation to reject ELF binary with .txt extension' + assert_match(/invalid file MIME type/i, last_response.body) + ensure + unit.destroy + end + + test 'rejects PHP script with image extension' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition( + unit: unit, + upload_requirements: [{ 'key' => 'file0', 'name' => 'Image', 'type' => 'image' }] + ) + + add_auth_header_for(user: project.student) + + php_payload = '' + with_tempfile('.jpg', php_payload) do |f| + uploaded = Rack::Test::UploadedFile.new(f.path, 'image/jpeg', true) + post_submission(project, td, uploaded) + end + + assert_equal 403, last_response.status, + 'Expected MIME validation to reject PHP payload with .jpg extension' + assert_match(/invalid file MIME type/i, last_response.body) + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 4. File-signature mismatch where the policy uses signature checks + # FileHelper uses FileMagic (libmagic) to detect the actual MIME type. + # Files whose magic bytes disagree with the declared type must be rejected. + # ───────────────────────────────────────────────────────────────────────────── + + test 'accept_file rejects file whose magic bytes mismatch the kind' do + # Use FileHelper directly to confirm the signature check, independent of + # the API layer. + result = with_tempfile('.pdf', "PK\x03\x04rest of zip", binary: true) do |f| + FileHelper.accept_file( + { filename: 'report.pdf', 'tempfile' => f }, + 'Report', + 'document' + ) + end + + assert_not result[:accepted], + 'Expected accept_file to reject a file whose magic bytes are ZIP but kind is document' + assert_includes result[:msg].downcase, 'mime', + 'Expected rejection message to mention MIME type mismatch' + end + + test 'accept_file rejects HTML file presented as an image' do + html_content = '' + result = with_tempfile('.png', html_content) do |f| + FileHelper.accept_file( + { filename: 'photo.png', 'tempfile' => f }, + 'Photo', + 'image' + ) + end + + assert_not result[:accepted], + 'Expected accept_file to reject HTML content submitted as an image' + end + + # ───────────────────────────────────────────────────────────────────────────── + # 5. Empty, oversized, malformed, and unsupported files + # ───────────────────────────────────────────────────────────────────────────── + + test 'empty file is rejected by MIME validation' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + + add_auth_header_for(user: project.student) + + with_tempfile('.py', '') do |f| + uploaded = Rack::Test::UploadedFile.new(f.path, 'text/plain', true) + post_submission(project, td, uploaded) + end + + assert_equal 403, last_response.status, + "Expected MIME validation to reject an empty file, got: #{last_response.body}" + assert_match(/invalid file MIME type/i, last_response.body) + ensure + unit.destroy + end + + test 'rejects file exceeding the configured max_file_size' do + original_max = Doubtfire::Application.config.max_file_size + Doubtfire::Application.config.max_file_size = 1_024 # 1 KB + + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + + add_auth_header_for(user: project.student) + + with_tempfile('.py', 'x' * 2_048) do |f| + uploaded = Rack::Test::UploadedFile.new(f.path, 'text/plain', true) + post_submission(project, td, uploaded) + end + + assert_equal 403, last_response.status, + 'Expected upload validation to reject a file exceeding max_file_size' + assert_match(/exceeds the \d+MB file limit/i, last_response.body) + ensure + unit.destroy + Doubtfire::Application.config.max_file_size = original_max + end + + test 'rejects malformed / corrupted PDF' do + result = File.open(Rails.root.join('test_files/submissions/corrupted.pdf')) do |f| + FileHelper.accept_file( + { filename: 'corrupted.pdf', 'tempfile' => f }, + 'Report', + 'document' + ) + end + + assert_not result[:accepted], + 'Expected accept_file to reject a corrupted PDF' + assert_match(/corrupt/i, result[:msg]) + end + + test 'rejects unsupported file extension' do + result = with_tempfile('.exe', "MZ#{"\x90" * 10}", binary: true) do |f| + FileHelper.accept_file( + { filename: 'malware.exe', 'tempfile' => f }, + 'Code', + 'code' + ) + end + + assert_not result[:accepted], + 'Expected accept_file to reject an .exe file' + assert_includes result[:msg].downcase, 'extension' + end + + test 'rejects malformed zip file' do + result = Tempfile.create(['bad', '.zip']) do |f| + f.write('this is not a zip file at all') + f.flush + FileHelper.accept_file( + { filename: 'submission.zip', 'tempfile' => f }, + 'Archive', + 'zip' + ) + end + + assert_not result[:accepted], + 'Expected accept_file to reject a malformed zip file' + end + + # ───────────────────────────────────────────────────────────────────────────── + # 6. Path traversal, control characters, and unusual Unicode filenames + # ───────────────────────────────────────────────────────────────────────────── + + test 'rejects zip containing path traversal entry' do + Tempfile.create(['traversal', '.zip']) do |zip_file| + Zip::File.open(zip_file.path, Zip::File::CREATE) do |zip| + zip.get_output_stream('../../../etc/passwd') { |io| io.write('root:x:0:0') } + end + + result = FileHelper.accept_file( + { filename: 'submission.zip', 'tempfile' => zip_file }, + 'Archive', + 'zip' + ) + + assert_not result[:accepted], + 'Expected rejection for zip with path traversal entry' + assert_match(/unsafe path/i, result[:msg]) + end + end + + test 'sanitized_filename strips path separators and control characters' do + dangerous_names = [ + "../../../etc/passwd", + "..\\..\\windows\\system32\\cmd.exe", + "file\x00name.txt", # null byte + "file\x01name.txt", # SOH control char + "file\nname.txt", # newline + "file\rname.txt" # carriage return + ] + + dangerous_names.each do |name| + sanitized = FileHelper.sanitized_filename(name) + + assert_not_includes sanitized, '..', "sanitized_filename should remove '..' from '#{name}'" + assert_not_includes sanitized, '/', "sanitized_filename should remove '/' from '#{name}'" + assert_not_includes sanitized, '\\', "sanitized_filename should remove backslash from '#{name}'" + assert_not_includes sanitized, "\x00", "sanitized_filename should remove null byte from '#{name}'" + # Control characters (ASCII 0-31) should be stripped. + assert_equal sanitized, sanitized.gsub(/[[:cntrl:]]/, ''), + "sanitized_filename should remove control characters from '#{name}'" + end + end + + test 'sanitized_path does not allow traversal outside base directory' do + traversal_paths = [ + ['../secret', 'data'], + ['../../etc', 'passwd'], + ['valid', '../escape'] + ] + + traversal_paths.each do |parts| + result = FileHelper.sanitized_path(*parts) + assert_no_match(/\.\./, result, + "sanitized_path should not contain '..' for input #{parts.inspect}") + end + end + + test 'submission is accepted with a valid Unicode filename' do + # Unicode filenames that are unusual but legitimate should not crash the + # system, and accepted files should be stored safely. + unicode_name = "提å‡ēį‰Š_\u4E2D\u6587_file.py" + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + + add_auth_header_for(user: project.student) + + with_tempfile('.py', "print('hello')") do |f| + uploaded = Rack::Test::UploadedFile.new(f.path, 'text/plain', false, original_filename: unicode_name) + post_submission(project, td, uploaded) + end + + assert_equal 201, last_response.status, + "Expected a valid Unicode filename to be accepted, got: #{last_response.body}" + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 7. Download headers and active-content rendering behaviour + # Submission PDFs must be served with Content-Disposition: attachment and a + # safe Content-Type so browsers do not execute them inline. + # ───────────────────────────────────────────────────────────────────────────── + + test 'submission download is served as attachment not inline' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition( + unit: unit, + upload_requirements: [{ 'key' => 'file0', 'name' => 'Report', 'type' => 'document' }] + ) + + add_auth_header_for(user: project.student) + + data = with_file('test_files/submissions/valid.pdf', 'application/pdf', + { trigger: 'ready_for_feedback' }) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data + assert_equal 201, last_response.status, last_response.body + + get "/api/projects/#{project.id}/task_def_id/#{td.id}/submission?as_attachment=true" + + content_disp = last_response.headers['Content-Disposition'].to_s + assert_match(/attachment/i, content_disp, + 'Submission download should use Content-Disposition: attachment when requested') + ensure + unit.destroy + end + + test 'submission endpoint returns application/pdf content type' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition( + unit: unit, + upload_requirements: [{ 'key' => 'file0', 'name' => 'Report', 'type' => 'document' }] + ) + + add_auth_header_for(user: project.student) + + data = with_file('test_files/submissions/valid.pdf', 'application/pdf', + { trigger: 'ready_for_feedback' }) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data + assert_equal 201, last_response.status, last_response.body + + get "/api/projects/#{project.id}/task_def_id/#{td.id}/submission" + + content_type = last_response.headers['Content-Type'].to_s + assert_match(%r{application/pdf}, content_type, + 'Submission GET should return application/pdf, not text/html or similar') + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 8. Macro-enabled documents, archives, and encrypted files + # ───────────────────────────────────────────────────────────────────────────── + + test 'rejects encrypted PDF' do + result = File.open(Rails.root.join('test_files/submissions/encrypted.pdf')) do |f| + FileHelper.accept_file( + { filename: 'encrypted.pdf', 'tempfile' => f }, + 'Report', + 'document' + ) + end + + assert_not result[:accepted], + 'Expected accept_file to reject an encrypted PDF' + assert_match(/encrypt/i, result[:msg]) + end + + test 'rejects unsupported Word document extension' do + # Production accepts PDF only for document uploads. DOCX is not a known + # extension and no conversion path runs from FileHelper.accept_file. + with_tempfile('.docx', "PK\x03\x04fake docx content", binary: true) do |f| + result = FileHelper.accept_file( + { filename: 'report.docx', 'tempfile' => f }, + 'Report', + 'document' + ) + + assert_not result[:accepted], 'Expected DOCX to be rejected for document uploads' + assert_equal 'invalid file extension.', result[:msg] + end + end + + test 'rejects zip containing nested archive' do + Tempfile.create(['nested', '.zip']) do |zip_file| + Zip::File.open(zip_file.path, Zip::File::CREATE) do |zip| + zip.get_output_stream('src/vendor.zip') { |io| io.write("PK#{"\x00" * 10}") } + end + + result = FileHelper.accept_file( + { filename: 'submission.zip', 'tempfile' => zip_file }, + 'Archive', + 'zip' + ) + + assert_not result[:accepted], + 'Expected rejection for zip containing a nested archive' + assert_match(/nested/i, result[:msg]) + end + end + + test 'rejects .xlsm (macro-enabled Excel) file submitted as a document' do + # .xlsm is not in the allowed extension list for 'document' kind. + result = with_tempfile('.xlsm', "PK\x03\x04fake xlsm", binary: true) do |f| + FileHelper.accept_file( + { filename: 'macro_sheet.xlsm', 'tempfile' => f }, + 'Spreadsheet', + 'document' + ) + end + + assert_not result[:accepted], + 'Expected accept_file to reject a macro-enabled spreadsheet as a document' + end + + # ───────────────────────────────────────────────────────────────────────────── + # 9. Sequential duplicate-upload / storage-exhaustion controls + # The zip abuse defences limit per-archive resource use. The API also + # rejects a later duplicate while the first accepted upload is queued. + # A true simultaneous race requires a separate multi-connection test. + # ───────────────────────────────────────────────────────────────────────────── + + test 'zip compression-ratio limit is enforced' do + # A zip that compresses highly repeated data is a potential zip bomb. + original_max = Doubtfire::Application.config.max_file_size + original_ratio = Doubtfire::Application.config.zip_compression_ratio_limit + Doubtfire::Application.config.max_file_size = 100_000_000 + Doubtfire::Application.config.zip_compression_ratio_limit = 5 + + Tempfile.create(['bomb', '.zip']) do |zip_file| + Zip::File.open(zip_file.path, Zip::File::CREATE) do |zip| + # Write 1 MB of all-zeroes – compresses to ~1 KB, ratio >> 5. + zip.get_output_stream('zeros.txt') { |io| io.write("\x00" * 1_000_000) } + end + + result = FileHelper.validate_zip_upload(zip_file.path, 'bomb.zip') + + assert_not result[:valid], + 'Expected zip with extreme compression ratio to be rejected' + assert_match(/ratio/i, result[:msg]) + end + ensure + Doubtfire::Application.config.max_file_size = original_max + Doubtfire::Application.config.zip_compression_ratio_limit = original_ratio + end + + test 'zip entry count limit is enforced' do + original_limit = Doubtfire::Application.config.zip_entry_limit + Doubtfire::Application.config.zip_entry_limit = 3 + + Tempfile.create(['manyfiles', '.zip']) do |zip_file| + Zip::File.open(zip_file.path, Zip::File::CREATE) do |zip| + 5.times { |i| zip.get_output_stream("file_#{i}.txt") { |io| io.write('x') } } + end + + result = FileHelper.validate_zip_upload(zip_file.path, 'manyfiles.zip') + + assert_not result[:valid], + 'Expected zip with too many entries to be rejected' + assert_match(/too many files/i, result[:msg]) + end + ensure + Doubtfire::Application.config.zip_entry_limit = original_limit + end + + test 'total uncompressed size limit is enforced across multiple files in zip' do + original_max = Doubtfire::Application.config.max_file_size + original_multiplier = Doubtfire::Application.config.zip_uncompressed_size_multiplier + Doubtfire::Application.config.max_file_size = 1_000 + Doubtfire::Application.config.zip_uncompressed_size_multiplier = 2 + + Tempfile.create(['bigzip', '.zip']) do |zip_file| + Zip::File.open(zip_file.path, Zip::File::CREATE) do |zip| + 3.times { |i| zip.get_output_stream("part_#{i}.txt") { |io| io.write('a' * 900) } } + end + + result = FileHelper.validate_zip_upload(zip_file.path, 'bigzip.zip') + + assert_not result[:valid], + 'Expected rejection when combined uncompressed zip size exceeds limit' + assert_match(/uncompressed size limit/i, result[:msg]) + end + ensure + Doubtfire::Application.config.max_file_size = original_max + Doubtfire::Application.config.zip_uncompressed_size_multiplier = original_multiplier + end + + test 'sequential duplicate upload is blocked while first submission is queued' do + # This deliberately exercises a later request, not a simultaneous race. The + # first request leaves its payload in :new; the second must be rejected + # without changing state, storing another payload, or enqueuing another job. + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + task = project.task_for_task_definition(td) + + add_auth_header_for(user: project.student) + + jobs_before = AcceptSubmissionJob.jobs.size + data = with_file('test_files/submissions/normal.py', 'text/plain', + { trigger: 'ready_for_feedback' }) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data + assert_equal 201, last_response.status, + "First upload should succeed (got: #{last_response.body})" + assert_equal jobs_before + 1, AcceptSubmissionJob.jobs.size, + 'First upload must enqueue exactly one processing job' + assert_equal :ready_for_feedback, task.reload.status, + 'First upload must perform the requested state transition' + + queued_dir = FileHelper.student_work_dir(:new, task, false) + payloads_after_first = Dir.glob(File.join(queued_dir, '*')).select { |path| File.file?(path) } + assert_equal 1, payloads_after_first.size, + 'First upload must leave exactly one payload queued for processing' + + first_submission_count = TaskSubmission.where(task: task).count + first_submission_date = task.submission_date + jobs_after_first = AcceptSubmissionJob.jobs.size + + data2 = with_file('test_files/submissions/normal.py', 'text/plain', + { trigger: 'need_help' }) + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data2 + + assert_equal 403, last_response.status, + 'Second upload while processing should be blocked with 403' + assert_match(/already being processed/i, last_response.body, + 'Response should explain the submission is already being processed') + assert_equal jobs_after_first, AcceptSubmissionJob.jobs.size, + 'Rejected duplicate must not enqueue another processing job' + assert_equal payloads_after_first, Dir.glob(File.join(queued_dir, '*')).select { |path| File.file?(path) }, + 'Rejected duplicate must not add or replace queued payloads' + assert_equal first_submission_count, TaskSubmission.where(task: task).count, + 'Rejected duplicate must not add a submission row' + assert_equal :ready_for_feedback, task.reload.status, + 'Rejected duplicate must not change the accepted submission state' + assert_equal first_submission_date, task.submission_date, + 'Rejected duplicate must not change the accepted submission timestamp' + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 10. Cleanup before staging for rejected uploads + # Early validation failures must not create task-owned staging paths. + # Post-staging failure and abandoned-worker cleanup remain open findings. + # ───────────────────────────────────────────────────────────────────────────── + + test 'early MIME rejection creates no task-owned staging artifacts' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + task = project.task_for_task_definition(td) + + add_auth_header_for(user: project.student) + + owned_staging_paths = [ + File.join(Dir.tmpdir, 'doubtfire', 'new', task.id.to_s), + FileHelper.student_work_dir(:new, task, false), + FileHelper.student_work_dir(:in_process, task, false) + ] + assert owned_staging_paths.none? { |path| File.exist?(path) }, + 'Fresh task must not already have submission staging paths' + + jobs_before = AcceptSubmissionJob.jobs.size + submissions_before = TaskSubmission.where(task: task).count + status_before = task.status + + elf_magic = "\x7fELF\x02\x01\x01\x00#{"\x00" * 8}" + with_tempfile('.txt', elf_magic, binary: true) do |f| + post_submission(project, td, Rack::Test::UploadedFile.new(f.path, 'text/plain', true)) + end + + # Assert the upload reached file validation and was rejected for its MIME, + # rather than passing on an unrelated authentication or processing error. + assert_equal 403, last_response.status, + "Expected MIME validation to reject the upload, got: #{last_response.body}" + assert_match(/invalid file MIME type/i, last_response.body) + assert owned_staging_paths.none? { |path| File.exist?(path) }, + 'Early rejection must not create task-owned staging files or directories' + assert_equal jobs_before, AcceptSubmissionJob.jobs.size, + 'Early rejection must not enqueue submission processing' + assert_equal submissions_before, TaskSubmission.where(task: task).count, + 'Early rejection must not create a submission row' + assert_equal status_before, task.reload.status, + 'Early rejection must not transition task state' + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 11. Logs must not contain file content, sensitive names, or unnecessary + # student information + # ───────────────────────────────────────────────────────────────────────────── + + test 'rejected submission logs a safe marker without content or student identifiers' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + student = project.student + td = create_task_definition(unit: unit) + project.task_for_task_definition(td) + + add_auth_header_for(user: student) + + sensitive_content = 'SENSITIVE_STUDENT_DATA_12345' + unsafe_filename = "rejected-#{student.username}-#{student.email}.txt" + elf_magic = "\x7fELF\x02\x01\x01\x00#{"\x00" * 8}" + + logged = capture_rails_logs do + with_tempfile('.txt', elf_magic + sensitive_content, binary: true) do |f| + uploaded = Rack::Test::UploadedFile.new( + f.path, + 'text/plain', + true, + original_filename: unsafe_filename + ) + post_submission(project, td, uploaded) + end + end + + assert_equal 403, last_response.status, + 'Rejected submission must reach and fail MIME validation' + assert_match(/invalid file MIME type/i, last_response.body) + assert_includes logged, 'File MIME check failed', + 'Expected safe validation marker proving the rejection path logged' + assert_not_includes logged, sensitive_content, + 'Rejected submission log must not include file content' + assert_not_includes logged, student.email, + 'Rejected submission log must not include student email' + assert_not_includes logged, student.username, + 'Rejected submission log must not include student username' + assert_not_includes logged, unsafe_filename, + 'Rejected submission log must not include the client filename' + ensure + unit.destroy + end + + test 'accepted submission logs safe markers without content or student identifiers' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + student = project.student + td = create_task_definition(unit: unit) + project.task_for_task_definition(td) + + add_auth_header_for(user: student) + + sensitive_content = "print('SENSITIVE_STUDENT_CODE_67890')" + unsafe_filename = "accepted-#{student.username}-#{student.email}.py" + + logged = capture_rails_logs do + with_tempfile('.py', sensitive_content) do |f| + uploaded = Rack::Test::UploadedFile.new( + f.path, + 'text/plain', + true, + original_filename: unsafe_filename + ) + post_submission(project, td, uploaded) + end + end + + assert_equal 201, last_response.status, + 'Accepted submission logging test must exercise the successful path' + assert_includes logged, 'Uploaded file is accepted', + 'Expected safe file-validation success marker' + assert_includes logged, 'Submission accepted! Status for task', + 'Expected safe submission success marker' + assert_not_includes logged, sensitive_content, + 'Accepted submission log must not include file content' + assert_not_includes logged, student.email, + 'Accepted submission log must not include student email' + assert_not_includes logged, student.username, + 'Accepted submission log must not include student username' + assert_not_includes logged, unsafe_filename, + 'Accepted submission log must not include the client filename' + ensure + unit.destroy + end + + test 'comment attachment logs a safe marker without content or student identifiers' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + student = project.student + td = create_task_definition(unit: unit) + task = project.task_for_task_definition(td) + + add_auth_header_for(user: student) + + sensitive_comment = 'SENSITIVE_COMMENT_BODY_24680' + unsafe_filename = "comment-#{student.username}-#{student.email}.pdf" + pdf_path = Rails.root.join('test_files/submissions/00_question.pdf') + + logged = capture_rails_logs do + post "/api/projects/#{project.id}/task_def_id/#{td.id}/comments", + comment: sensitive_comment, + attachment: Rack::Test::UploadedFile.new( + pdf_path, + 'application/pdf', + true, + original_filename: unsafe_filename + ) + end + + assert_equal 201, last_response.status, + 'Comment logging test must exercise a successful attachment upload' + assert_includes logged, "user_id=#{student.id} added comment for task #{task.id}", + 'Expected safe comment audit marker using an internal user id' + assert_includes logged, 'Uploaded file is accepted', + 'Expected safe attachment-validation success marker' + assert_not_includes logged, sensitive_comment, + 'Comment attachment log must not include comment content' + assert_not_includes logged, student.email, + 'Comment attachment log must not include student email' + assert_not_includes logged, student.username, + 'Comment attachment log must not include student username' + assert_not_includes logged, unsafe_filename, + 'Comment attachment log must not include the client filename' + ensure + unit.destroy + end + + # ───────────────────────────────────────────────────────────────────────────── + # 13. Attachment retention and deletion behaviour + # Deleting a comment must remove its attachment file from disk. + # Deleting a task must remove its submission files from disk. + # ───────────────────────────────────────────────────────────────────────────── + + test 'deleting a comment with an attachment removes the file from disk' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + task = project.task_for_task_definition(td) + student = project.student + + add_auth_header_for(user: student) + + # Post a comment with a PDF attachment via the API. + pdf_path = Rails.root.join('test_files/submissions/00_question.pdf') + post "/api/projects/#{project.id}/task_def_id/#{td.id}/comments", + comment: 'test attachment', + attachment: Rack::Test::UploadedFile.new(pdf_path, 'application/pdf', true) + + assert_equal 201, last_response.status, last_response.body + + comment = task.comments.last + attachment_path = comment.attachment_path + + assert File.exist?(attachment_path), + 'Attachment file should exist on disk after upload' + + comment.destroy + + assert_not File.exist?(attachment_path), + 'Attachment file must be removed from disk when the comment is deleted' + ensure + unit.destroy + end + + test 'deleting a task comment via API removes the attachment from disk' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + task = project.task_for_task_definition(td) + student = project.student + + add_auth_header_for(user: student) + + pdf_path = Rails.root.join('test_files/submissions/00_question.pdf') + post "/api/projects/#{project.id}/task_def_id/#{td.id}/comments", + comment: 'test attachment', + attachment: Rack::Test::UploadedFile.new(pdf_path, 'application/pdf', true) + + assert_equal 201, last_response.status, last_response.body + + comment = task.comments.last + attachment_path = comment.attachment_path + + assert File.exist?(attachment_path), 'Attachment must exist before deletion' + + delete "/api/projects/#{project.id}/task_def_id/#{td.id}/comments/#{comment.id}" + + assert_includes [200, 204], last_response.status, + 'Expected 200 or 204 on comment deletion' + assert_not File.exist?(attachment_path), + 'Attachment file must be removed from disk after API comment deletion' + ensure + unit.destroy + end + + test 'comment attachment returns 404 after comment is deleted' do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + project = unit.active_projects.first + td = create_task_definition(unit: unit) + task = project.task_for_task_definition(td) + student = project.student + + add_auth_header_for(user: student) + + pdf_path = Rails.root.join('test_files/submissions/00_question.pdf') + post "/api/projects/#{project.id}/task_def_id/#{td.id}/comments", + comment: 'test attachment', + attachment: Rack::Test::UploadedFile.new(pdf_path, 'application/pdf', true) + + assert_equal 201, last_response.status, last_response.body + + comment = task.comments.last + comment_id = comment.id + comment.destroy + + get "/api/projects/#{project.id}/task_def_id/#{td.id}/comments/#{comment_id}" + + assert_equal 404, last_response.status, + 'Fetching a deleted comment attachment must return 404' + ensure + unit.destroy + end + +end diff --git a/test/api/users_test.rb b/test/api/users_test.rb index 05b21395c5..13b14b57e3 100644 --- a/test/api/users_test.rb +++ b/test/api/users_test.rb @@ -12,7 +12,10 @@ def app def assert_users_model_response(response_data, user_model, keys = nil) if keys.nil? keys = %w[id student_id email first_name last_name username nickname receive_task_notifications - receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup] + receive_portfolio_notifications receive_feedback_notifications display_peer_progress + opt_in_to_research has_run_first_time_setup] + assert_not response_data.key?('theme_preference') + assert_not response_data.key?('theme_preference_updated_at') end assert_json_matches_model(user_model, response_data, keys) @@ -50,7 +53,7 @@ def test_get_users assert_equal expected_data.count, last_response_body.count # What are the keys we expect in the data that match the model - so we can check these - response_keys = %w[first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup] + response_keys = %w[first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications display_peer_progress opt_in_to_research has_run_first_time_setup] # Loop through all of the responses last_response_body.each do | data | @@ -58,6 +61,8 @@ def test_get_users user = User.find(data['id']) # Match json with object assert_json_matches_model(user, data, response_keys) + assert_not data.key?('theme_preference') + assert_not data.key?('theme_preference_updated_at') end end @@ -76,8 +81,10 @@ def test_get_a_users_details assert_equal 200, last_response.status # Check the returned details match as expected - response_keys = %w(first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup) + response_keys = %w(first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications display_peer_progress opt_in_to_research has_run_first_time_setup) assert_json_matches_model(expected_user, returned_user, response_keys) + assert_not returned_user.key?('theme_preference') + assert_not returned_user.key?('theme_preference_updated_at') end def test_get_convenors @@ -87,6 +94,10 @@ def test_get_convenors get '/api/users/convenors' assert_equal 200, last_response.status + last_response_body.each do |user| + assert_not user.key?('theme_preference') + assert_not user.key?('theme_preference_updated_at') + end end def test_get_tutors @@ -96,6 +107,10 @@ def test_get_tutors get '/api/users/tutors' assert_equal 200, last_response.status + last_response_body.each do |user| + assert_not user.key?('theme_preference') + assert_not user.key?('theme_preference_updated_at') + end end def test_get_no_token @@ -134,6 +149,7 @@ def test_post_create_user assert_equal pre_count + 1, User.all.length assert_users_model_response last_response_body, User.last + assert User.last.display_peer_progress? assert_equal 201, last_response.status end @@ -335,6 +351,139 @@ def test_put_update_user_existing_email assert_equal 400, last_response.status end + def test_put_update_peer_progress_display_preference + user = User.second + add_auth_header_for(user: User.first) + + put_json "/api/users/#{user.id}", { + user: { display_peer_progress: false } + } + + assert_equal 200, last_response.status + assert_equal false, last_response_body['display_peer_progress'] + assert_not user.reload.display_peer_progress? + + put_json "/api/users/#{user.id}", { + user: { display_peer_progress: true } + } + + assert_equal 200, last_response.status + assert_equal true, last_response_body['display_peer_progress'] + assert user.reload.display_peer_progress? + end + + def test_theme_preference_is_nullable_until_the_user_chooses + user = User.first + user.update!(theme_preference: nil) + add_auth_header_for(user: user) + + get "/api/users/#{user.id}" + + assert_equal 200, last_response.status + assert last_response_body.key?('theme_preference') + assert last_response_body.key?('theme_preference_updated_at') + assert_nil last_response_body['theme_preference'] + assert_nil last_response_body['theme_preference_updated_at'] + end + + def test_put_update_theme_preference_stamps_and_serializes_its_timestamp + user = User.first + user.update!(theme_preference: nil) + add_auth_header_for(user: user) + chosen_at = Time.zone.parse('2026-08-30 10:00:00 UTC') + + travel_to chosen_at do + put_json "/api/users/#{user.id}", { + user: { theme_preference: 'dark' } + } + end + + assert_equal 200, last_response.status + assert_equal 'dark', last_response_body['theme_preference'] + assert_equal chosen_at, Time.iso8601(last_response_body['theme_preference_updated_at']) + assert_equal chosen_at, user.reload.theme_preference_updated_at + end + + def test_put_same_theme_preference_refreshes_the_sync_timestamp + user = User.first + first_choice_at = Time.zone.parse('2026-08-30 10:00:00 UTC') + travel_to first_choice_at do + user.update!(theme_preference: 'dark') + end + add_auth_header_for(user: user) + + synchronization_at = first_choice_at + 2.hours + travel_to synchronization_at do + put_json "/api/users/#{user.id}", { + user: { theme_preference: 'dark' } + } + end + + assert_equal 200, last_response.status + assert_equal 'dark', last_response_body['theme_preference'] + assert_equal synchronization_at, Time.iso8601(last_response_body['theme_preference_updated_at']) + assert_equal synchronization_at, user.reload.theme_preference_updated_at + end + + def test_put_clear_theme_preference_restores_the_never_chosen_state + user = User.first + user.update!(theme_preference: 'dark') + add_auth_header_for(user: user) + + put_json "/api/users/#{user.id}", { + user: { theme_preference: nil } + } + + assert_equal 200, last_response.status + assert last_response_body.key?('theme_preference') + assert last_response_body.key?('theme_preference_updated_at') + assert_nil last_response_body['theme_preference'] + assert_nil last_response_body['theme_preference_updated_at'] + assert_nil user.reload.theme_preference + assert_nil user.theme_preference_updated_at + end + + def test_non_self_update_ignores_theme_preference_and_omits_it_from_response + current_user = User.first + other_user = User.second + original_choice_at = Time.zone.parse('2026-08-30 10:00:00 UTC') + travel_to original_choice_at do + other_user.update!(theme_preference: 'dark') + end + add_auth_header_for(user: current_user) + + put_json "/api/users/#{other_user.id}", { + user: { + nickname: 'Updated by administrator', + theme_preference: 'light' + } + } + + assert_equal 200, last_response.status + assert_equal 'Updated by administrator', other_user.reload.nickname + assert_equal 'dark', other_user.theme_preference + assert_equal original_choice_at, other_user.theme_preference_updated_at + assert_not last_response_body.key?('theme_preference') + assert_not last_response_body.key?('theme_preference_updated_at') + end + + def test_put_invalid_theme_preference_keeps_the_existing_choice_and_timestamp + user = User.first + chosen_at = Time.zone.parse('2026-08-30 10:00:00 UTC') + travel_to chosen_at do + user.update!(theme_preference: 'dark') + end + add_auth_header_for(user: user) + + put_json "/api/users/#{user.id}", { + user: { theme_preference: 'sepia' } + } + + assert_equal 400, last_response.status + assert_equal 'dark', user.reload.theme_preference + assert_equal chosen_at, user.theme_preference_updated_at + end + def test_put_update_user_invalid_email user = User.second diff --git a/test/config/deakin_config_test.rb b/test/config/deakin_config_test.rb index 25da492eec..c4f4f81583 100644 --- a/test/config/deakin_config_test.rb +++ b/test/config/deakin_config_test.rb @@ -24,6 +24,14 @@ def teardown Doubtfire::Application.config.institution_settings = @@backup end + def test_value_before_delimiter_uses_a_bounded_string_search + settings = Doubtfire::Application.config.institution_settings + + assert_equal 'student', settings.value_before_delimiter('student@example.edu.au', '@') + assert_equal 'SIT999', settings.value_before_delimiter('SIT999_CLASS', '_') + assert_nil settings.value_before_delimiter('a' * 100_000, '_') + end + def test_sync_deakin_unit WebMock.reset_executed_requests! 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/sidekiq_config_test.rb b/test/config/sidekiq_config_test.rb new file mode 100644 index 0000000000..e23501685a --- /dev/null +++ b/test/config/sidekiq_config_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'erb' +require 'yaml' + +class SidekiqConfigTest < ActiveSupport::TestCase + def test_production_worker_consumes_both_notification_channels_before_default + rendered = ERB.new(Rails.root.join('config/sidekiq.yml').read).result + config = YAML.safe_load(rendered, permitted_classes: [Symbol], aliases: true) + + assert_equal %w[mailers notifications default], config.fetch(:queues) + 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/factories/notification_factory.rb b/test/factories/notification_factory.rb new file mode 100644 index 0000000000..923a1eacff --- /dev/null +++ b/test/factories/notification_factory.rb @@ -0,0 +1,40 @@ +require 'faker' + +FactoryBot.define do + factory :notification do + user + notification_type { 'general' } + event { "#{notification_type}_event" } + message { Faker::Lorem.sentence } + link { nil } + read_at { nil } + + trait :task do + notification_type { 'task' } + end + + trait :feedback do + notification_type { 'feedback' } + end + + trait :portfolio do + notification_type { 'portfolio' } + end + + trait :extension do + notification_type { 'extension' } + end + + trait :general do + notification_type { 'general' } + end + + trait :read do + read_at { Time.zone.now } + end + + trait :unread do + read_at { nil } + end + end +end diff --git a/test/factories/peer_progress_snapshot_factory.rb b/test/factories/peer_progress_snapshot_factory.rb new file mode 100644 index 0000000000..809fd565de --- /dev/null +++ b/test/factories/peer_progress_snapshot_factory.rb @@ -0,0 +1,26 @@ +FactoryBot.define do + factory :peer_progress_snapshot do + unit do + create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0 + ) + end + + task_definition do + create( + :task_definition, + unit: unit, + target_grade: 0, + outcome_count: 0 + ) + end + + target_grade { task_definition.target_grade } + submitted_percentage { 50.0 } + cohort_size { 10 } + calculated_at { Time.current } + end +end diff --git a/test/factories/push_subscriptions_factory.rb b/test/factories/push_subscriptions_factory.rb new file mode 100644 index 0000000000..9b4a87c8c1 --- /dev/null +++ b/test/factories/push_subscriptions_factory.rb @@ -0,0 +1,18 @@ +FactoryBot.define do + factory :push_subscription do + user + + # The endpoint is unique across the whole table, not per user, so the + # sequence alone is not enough. A test that leaks a row past its transaction + # would leave that endpoint in the database for the next run and the + # collision would look like a bug in whatever test built it second. + sequence(:endpoint) { |n| "https://fcm.googleapis.com/fcm/send/factory-#{n}-#{SecureRandom.hex(8)}" } + + # A throwaway browser key pair. The p256dh has to be a real prime256v1 + # public key because PushNotificationService encrypts against it through the + # web-push gem, and the gem cannot encrypt to a made up string. The auth + # secret has to decode to 16 bytes for the same reason. + p256dh { 'BJy8RpjMkwOPDIIXSu-FTe7OosAwY9G86_evhrn0jJbPnoxXjBYpn7aPHEIaRh3GxCzFvwYXjKWvtu3FEMaBQMY=' } + auth { 'CUkmaYqq8eINt1HTnFY65w==' } + end +end diff --git a/test/helpers/push_notification_helper.rb b/test/helpers/push_notification_helper.rb new file mode 100644 index 0000000000..903e88d06f --- /dev/null +++ b/test/helpers/push_notification_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module TestHelpers + module PushNotificationHelper + def parsed_push_notification(notification) + JSON.parse( + PushNotificationService.payload_for(notification) + ).fetch('notification') + end + + def assert_valid_push_payload(notification, expected_link:, expected_body: nil) + push = parsed_push_notification(notification) + data = push.fetch('data') + expected_body ||= notification.message.to_s.truncate( + PushNotificationService::MAX_BODY_LENGTH + ) + + assert push['title'].present?, 'push title must be present' + assert_equal expected_body, push['body'] + assert_operator( + push['body'].length, + :<=, + PushNotificationService::MAX_BODY_LENGTH + ) + assert_equal notification.id, data['notification_id'] + assert_equal expected_link, data['link'] + assert data['link'].present?, 'push data.link must be present' + + push + end + end +end diff --git a/test/lib/demo_data/all_features_scenario_test.rb b/test/lib/demo_data/all_features_scenario_test.rb new file mode 100644 index 0000000000..c62152f546 --- /dev/null +++ b/test/lib/demo_data/all_features_scenario_test.rb @@ -0,0 +1,331 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' +require Rails.root.join('lib/demo_data/all_features_scenario') + +class AllFeaturesScenarioTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + REFERENCE_TIME = Time.zone.parse('2026-08-24 10:00:00') + + setup do + @scenario = DemoData::AllFeaturesScenario.new( + reference_time: REFERENCE_TIME + ) + @original_profile = ENV.fetch('DF_DEMO_DATA_PROFILE', nil) + @original_minimum_cohort_size = ENV.fetch( + 'DF_PPI_MINIMUM_COHORT_SIZE', + nil + ) + @original_stale_after_hours = ENV.fetch( + 'DF_PPI_STALE_AFTER_HOURS', + nil + ) + clear_auth_header + end + + teardown do + restore_env('DF_DEMO_DATA_PROFILE', @original_profile) + restore_env( + 'DF_PPI_MINIMUM_COHORT_SIZE', + @original_minimum_cohort_size + ) + restore_env('DF_PPI_STALE_AFTER_HOURS', @original_stale_after_hours) + clear_auth_header + end + + test 'hard fails unless every safety guard matches' do + ENV['DF_DEMO_DATA_PROFILE'] = DemoData::AllFeaturesScenario::PROFILE_NAME + + error = assert_raises(DemoData::AllFeaturesScenario::SafetyError) do + @scenario.guard! + end + assert_includes error.message, 'Rails development' + + with_environment('development') do + @scenario.stub(:connected_database_name, 'ordinary-development') do + error = assert_raises(DemoData::AllFeaturesScenario::SafetyError) do + @scenario.guard! + end + assert_includes error.message, + DemoData::AllFeaturesScenario::DATABASE_NAME + end + + @scenario.stub( + :connected_database_name, + DemoData::AllFeaturesScenario::DATABASE_NAME + ) do + ENV.delete('DF_DEMO_DATA_PROFILE') + error = assert_raises(DemoData::AllFeaturesScenario::SafetyError) do + @scenario.guard! + end + assert_includes error.message, 'DF_DEMO_DATA_PROFILE=all-features' + end + end + end + + test 'recreates a complete privacy-safe all-features scenario' do + first_summary = run_scenario_without_delivery! + + assert_equal DemoData::AllFeaturesScenario::PROFILE_NAME, + first_summary.fetch(:profile) + assert_equal DemoData::AllFeaturesScenario::DEMO_USERNAME, + first_summary.fetch(:login) + assert_equal 'password', first_summary.fetch(:password) + + assert_units_and_task_states + assert_ppi_cohort_and_endpoint + assert_notifications_are_curated + assert_identities_are_generic + + counts_after_first_run = namespace_counts + second_summary = run_scenario_without_delivery! + + assert_equal counts_after_first_run, namespace_counts + assert_equal first_summary.except(:peer_progress), + second_summary.except(:peer_progress) + assert_equal 60.0, + second_summary.dig(:peer_progress, :submitted_percentage) + assert_equal 10.0, + second_summary.dig(:peer_progress, :completed_percentage) + assert second_summary.dig(:peer_progress, :distribution_available) + assert_equal DemoData::AllFeaturesScenario::NOTIFICATION_COUNT, + demo_student.notifications.count + + with_demo_safety { @scenario.cleanup! } + + assert_empty Unit.where(code: DemoData::AllFeaturesScenario::UNIT_CODES) + assert_empty User.where(username: DemoData::AllFeaturesScenario::USERNAMES) + assert_nil Campus.find_by( + abbreviation: DemoData::AllFeaturesScenario::CAMPUS_ABBREVIATION + ) + end + + private + + def run_scenario_without_delivery! + no_delivery = lambda do |*_args| + raise 'demo scenario must not invoke an external delivery channel' + end + + PushNotificationDeliveryJob.stub(:perform_async, no_delivery) do + NotificationEmailJob.stub(:perform_async, no_delivery) do + with_demo_safety { @scenario.run! } + end + end + end + + def with_demo_safety(&block) + ENV['DF_DEMO_DATA_PROFILE'] = DemoData::AllFeaturesScenario::PROFILE_NAME + with_environment('development') do + @scenario.stub( + :connected_database_name, + DemoData::AllFeaturesScenario::DATABASE_NAME, + &block + ) + end + end + + def with_environment(name, &) + environment = ActiveSupport::EnvironmentInquirer.new(name) + Rails.stub(:env, environment, &) + end + + def assert_units_and_task_states + scenario_units = Unit.where( + code: DemoData::AllFeaturesScenario::UNIT_CODES + ) + assert_equal DemoData::AllFeaturesScenario::UNIT_CODES.sort, + scenario_units.pluck(:code).sort + assert_equal DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES.sort, + scenario_units.where(active: true).pluck(:code).sort + assert_not Unit.find_by!( + code: DemoData::AllFeaturesScenario::PREVIOUS_UNIT_CODE + ).active? + + expected_statuses = { + 'OVERDUE' => :not_started, + 'DUE3' => :not_started, + 'DUE7' => :not_started, + 'FUTURE' => :not_started, + 'WORK' => :working_on_it, + 'DONE' => :complete + } + + DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES.each do |code| + project = demo_student.projects.joins(:unit).find_by!( + units: { code: code } + ) + assert_equal 0, project.target_grade + assert project.enrolled? + assert_equal expected_statuses.keys.sort, + project.tasks.joins(:task_definition) + .pluck('task_definitions.abbreviation').sort + + statuses = project.tasks.includes(:task_definition).to_h do |task| + [task.task_definition.abbreviation, task.status] + end + assert_equal expected_statuses, statuses + assert_not project.unit.send_notifications? + assert project.unit.task_definitions.none?(&:new_task_notifications_from?) + + definitions = project.unit.task_definitions.index_by(&:abbreviation) + assert_equal REFERENCE_TIME.to_date - 1, + definitions.fetch('OVERDUE').target_date.to_date + assert_equal REFERENCE_TIME.to_date + 2, + definitions.fetch('DUE3').target_date.to_date + assert_equal REFERENCE_TIME.to_date + 6, + definitions.fetch('DUE7').target_date.to_date + assert_operator definitions.fetch('FUTURE').start_date, + :>, + REFERENCE_TIME + end + + recommendation_unit_ids = TaskPrioritizationService + .new(demo_student, today: REFERENCE_TIME.to_date) + .call + .pluck(:unit_id) + .uniq + expected_unit_ids = Unit.where( + code: DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES + ).pluck(:id) + assert_equal expected_unit_ids.sort, recommendation_unit_ids.sort + end + + def assert_ppi_cohort_and_endpoint + unit = Unit.find_by!(code: DemoData::AllFeaturesScenario::PPI_UNIT_CODE) + definition = unit.task_definitions.find_by!( + abbreviation: DemoData::AllFeaturesScenario::PPI_TASK_ABBREVIATION + ) + project = demo_student.projects.find_by!(unit: unit) + snapshot = unit.peer_progress_snapshots.find_by!( + task_definition: definition, + target_grade: 0 + ) + + assert unit.peer_progress_enabled? + assert_equal DemoData::AllFeaturesScenario::COHORT_SIZE, + unit.active_projects.where(target_grade: 0).count + assert_equal DemoData::AllFeaturesScenario::SUBMITTED_COUNT, + unit.tasks.where.not(file_uploaded_at: nil).count + assert_equal DemoData::AllFeaturesScenario::COHORT_SIZE, + snapshot.cohort_size + assert_equal DemoData::AllFeaturesScenario::SUBMITTED_COUNT, + snapshot.submitted_count + assert_equal 60.0, snapshot.submitted_percentage.to_f + expected_status_counts = PeerProgressDistributionPolicy::STATUS_KEYS + .index_with { 0 } + .merge( + DemoData::AllFeaturesScenario::PPI_STATUS_COUNTS + .stringify_keys + ) + assert_equal expected_status_counts, snapshot.status_counts + + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = + PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE.to_s + ENV['DF_PPI_STALE_AFTER_HOURS'] = '48' + clear_auth_header + add_auth_header_for(user: demo_student) + get "/api/projects/#{project.id}/task_def_id/#{definition.id}/peer_progress" + + assert_equal 200, last_response.status, last_response.body + assert_equal 60.0, last_response_body.fetch('submitted_percentage') + assert_equal 10.0, last_response_body.fetch('completed_percentage') + assert_equal true, + last_response_body.fetch('distribution_available') + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + last_response_body.fetch('status_distribution').pluck('status') + assert_equal false, last_response_body.fetch('is_suppressed') + + verification = with_demo_safety { @scenario.verify! } + assert_equal 60.0, verification.fetch(:submitted_percentage) + assert_equal 10.0, verification.fetch(:completed_percentage) + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + verification.fetch(:status_distribution).pluck(:status) + end + + def assert_notifications_are_curated + notifications = demo_student.notifications.order(:created_at) + + assert_equal DemoData::AllFeaturesScenario::NOTIFICATION_COUNT, + notifications.count + assert_equal %w[feedback general portfolio task task task task], + notifications.pluck(:notification_type).sort + assert notifications.all?(&:delivered_at?) + assert(notifications.all? { |notification| notification.link.present? }) + assert(notifications.all? { |notification| notification.dedupe_key.present? }) + assert_equal 5, notifications.where(read_at: nil).count + assert_equal 2, notifications.where.not(read_at: nil).count + assert_equal 4, notifications.where(event: 'task_due_soon').count + assert_equal 0, PushSubscription.joins(:user).where( + users: { username: DemoData::AllFeaturesScenario::USERNAMES } + ).count + + travel_to REFERENCE_TIME do + active_demo_units = Unit.where( + code: DemoData::AllFeaturesScenario::CURRENT_UNIT_CODES, + active: true + ) + + Unit.stub(:where, active_demo_units) do + assert_no_difference('Notification.count') do + SendDueSoonRemindersJob.new.perform + end + end + end + end + + def assert_identities_are_generic + users = User.where(username: DemoData::AllFeaturesScenario::USERNAMES) + + assert_equal DemoData::AllFeaturesScenario::USERNAMES.length, users.count + assert(users.all? { |user| user.email.end_with?('.invalid') }) + assert(users.all? { |user| user.login_id == user.username }) + assert demo_student.valid_password?('password') + + peers = users.where( + username: DemoData::AllFeaturesScenario::PEER_USERNAMES + ) + assert(peers.all? { |peer| !peer.receive_task_notifications? }) + assert(peers.all? { |peer| !peer.receive_feedback_notifications? }) + assert(peers.all? { |peer| !peer.receive_portfolio_notifications? }) + assert users.all?(&:display_peer_progress?) + end + + def namespace_counts + { + campuses: Campus.where( + abbreviation: DemoData::AllFeaturesScenario::CAMPUS_ABBREVIATION + ).count, + units: Unit.where( + code: DemoData::AllFeaturesScenario::UNIT_CODES + ).count, + users: User.where( + username: DemoData::AllFeaturesScenario::USERNAMES + ).count, + projects: Project.joins(:unit).where( + units: { code: DemoData::AllFeaturesScenario::UNIT_CODES } + ).count, + tasks: Task.joins(project: :unit).where( + units: { code: DemoData::AllFeaturesScenario::UNIT_CODES } + ).count, + notifications: Notification.joins(:user).where( + users: { username: DemoData::AllFeaturesScenario::USERNAMES } + ).count, + push_subscriptions: PushSubscription.joins(:user).where( + users: { username: DemoData::AllFeaturesScenario::USERNAMES } + ).count + } + end + + def demo_student + User.find_by!(username: DemoData::AllFeaturesScenario::DEMO_USERNAME) + end + + def restore_env(name, value) + value.nil? ? ENV.delete(name) : ENV[name] = value + 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/mailers/azure_smtp_sender_test.rb b/test/mailers/azure_smtp_sender_test.rb new file mode 100644 index 0000000000..ff1b5f9d5b --- /dev/null +++ b/test/mailers/azure_smtp_sender_test.rb @@ -0,0 +1,71 @@ +require 'test_helper' + +class AzureSmtpSenderTest < ActionMailer::TestCase + HUMAN_SENDER = 'Tutor Example '.freeze + VERIFIED_SENDER = 'OnTrack '.freeze + Sender = Struct.new(:name) + + def test_non_production_communication_keeps_existing_from_header + mail = communication_email + + assert_equal ['tutor@example.edu'], mail.from + assert_nil mail.reply_to + end + + def test_production_communication_uses_verified_from_and_human_reply_to + with_production_sender do + mail = communication_email + + assert_equal ['noreply@ontrack.example'], mail.from + assert_equal ['tutor@example.edu'], mail.reply_to + end + end + + def test_production_system_mail_does_not_add_misleading_reply_to + previous_error_recipient = Doubtfire::Application.config.email_errors_to + Doubtfire::Application.config.email_errors_to = 'Operations ' + + with_production_sender do + mail = ErrorLogMailer.error_message('test', 'test message', StandardError.new('test error')) + + assert_equal ['noreply@ontrack.example'], mail.from + assert_nil mail.reply_to + end + ensure + Doubtfire::Application.config.email_errors_to = previous_error_recipient + end + + def test_production_mail_fails_closed_without_configured_sender + with_production_sender(nil) do + error = assert_raises(ArgumentError) { communication_email.message } + + assert_equal 'institution email_sender must be configured in production', error.message + end + end + + private + + def communication_email + CommunicationsMailer.communication_email( + to: 'Student Example ', + from: HUMAN_SENDER, + subject: 'Test communication', + body: 'Test body', + recipient: nil, + sender: Sender.new('Tutor Example'), + unit: nil, + rule: nil + ) + end + + def with_production_sender(sender = VERIFIED_SENDER, &) + institution = Doubtfire::Application.config.institution + previous_sender = institution[:email_sender] + production = ActiveSupport::EnvironmentInquirer.new('production') + institution[:email_sender] = sender + + Rails.stub(:env, production, &) + ensure + institution[:email_sender] = previous_sender + end +end diff --git a/test/mailers/communications_mailer_test.rb b/test/mailers/communications_mailer_test.rb new file mode 100644 index 0000000000..7ffeb389cd --- /dev/null +++ b/test/mailers/communications_mailer_test.rb @@ -0,0 +1,35 @@ +require 'test_helper' + +class CommunicationsMailerTest < ActionMailer::TestCase + # Regression for the nested-ERB-comment leak: the text part used + # `<%# Hi <%= ... %> %>`, and because an ERB comment ends at the first `%>` + # the trailing ` %>` printed literally in every email. Assert the rendered + # text part carries no raw ERB delimiter and the greeting and sign-off render. + def test_text_part_renders_without_leaking_erb + unit = FactoryBot.create :unit + recipient = FactoryBot.create :user + sender = FactoryBot.create :user, :convenor + + mail = CommunicationsMailer.communication_email( + to: recipient.email, + from: sender.email, + subject: 'Weekly update', + body: "First paragraph.\nSecond paragraph.", + recipient: recipient, + sender: sender, + unit: unit, + rule: nil + ) + + text = mail.text_part.body.to_s + + assert_not_includes text, '%>', "text part leaked a raw ERB delimiter:\n#{text}" + assert_not_includes text, '<%', "text part leaked a raw ERB delimiter:\n#{text}" + assert_includes text, "Hi #{recipient.first_name}," + assert_includes text, 'First paragraph.' + assert_includes text, 'Cheers,' + assert_includes text, "on behalf of #{sender.name}" + ensure + unit&.destroy! + end +end diff --git a/test/mailers/notifications_mailer_test.rb b/test/mailers/notifications_mailer_test.rb new file mode 100644 index 0000000000..84485e49ce --- /dev/null +++ b/test/mailers/notifications_mailer_test.rb @@ -0,0 +1,102 @@ +require 'test_helper' + +# EN-T04: every event's mailer templates render without raising, in both +# HTML and text. +# +# NOTE FOR FUTURE CONTRIBUTORS: this file does not discover events +# automatically. When you add a new event, add its name and notification +# type to the EVENTS hash below. A missing entry here means a broken or +# missing template for that event falls back silently to +# single_notification and nothing catches it. +class NotificationsMailerTest < ActionMailer::TestCase + # event => notification_type, matching the six events currently wired up + # in NotificationService.notify call sites across the app. + EVENTS = { + 'task_comment_created' => 'feedback', + 'extension_assessed' => 'extension', + 'group_membership_changed' => 'general', + 'new_task_available' => 'task', + 'task_due_date_changed' => 'task', + 'task_status_changed' => 'task' + }.freeze + + LINK = '/projects/1/dashboard/A1'.freeze + + EVENTS.each do |event, notification_type| + define_method("test_#{event}_renders_html_and_text") do + # The mailer falls back to the generic template when an event-specific + # template is missing, so require both event-specific template files. + %w[html text].each do |format| + template_path = Rails.root.join( + 'app', + 'views', + 'notifications_mailer', + "#{event}.#{format}.erb" + ) + + assert template_path.file?, + "#{event}: missing event-specific #{format} template" + end + + notification = FactoryBot.create( + :notification, + notification_type: notification_type, + event: event, + message: "A realistic message for #{event}, long enough to catch interpolation errors.", + link: LINK + ) + + mail = NotificationsMailer.single_notification(notification) + + assert mail.html_part.body.to_s.present?, "#{event}: HTML part did not render" + assert mail.text_part.body.to_s.present?, "#{event}: text part did not render" + end + + define_method("test_#{event}_subject_is_not_blank") do + notification = FactoryBot.create(:notification, notification_type: notification_type, event: event) + + mail = NotificationsMailer.single_notification(notification) + + assert mail.subject.present?, "#{event}: subject was blank" + + # Every event shares one subject today, built at + # app/mailers/notifications_mailer.rb:22. This assertion needs to + # change if anyone adds a per-event subject lookup. + expected_subject = "#{Doubtfire::Application.config.institution[:product_name]}: New notification" + assert_equal expected_subject, mail.subject, "#{event}: subject shape changed" + end + + define_method("test_#{event}_link_is_in_the_body") do + notification = FactoryBot.create( + :notification, + notification_type: notification_type, + event: event, + link: LINK + ) + + mail = NotificationsMailer.single_notification(notification) + expected_url = "#{Doubtfire::Application.config.institution[:host]}#{LINK}" + + assert_includes mail.html_part.body.to_s, expected_url, "#{event}: exact link missing from HTML body" + assert_includes mail.text_part.body.to_s, expected_url, "#{event}: exact link missing from text body" + end + end + + def test_configured_sender_is_used + institution = Doubtfire::Application.config.institution + previous_sender = institution[:email_sender] + institution[:email_sender] = 'notifications@example.edu' + + notification = FactoryBot.create( + :notification, + notification_type: 'feedback', + event: 'task_comment_created' + ) + + mail = NotificationsMailer.single_notification(notification) + + assert_equal ['notifications@example.edu'], mail.from + ensure + institution[:email_sender] = previous_sender + 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/context_model_helpers_test.rb b/test/models/context_model_helpers_test.rb new file mode 100644 index 0000000000..1b604e324d --- /dev/null +++ b/test/models/context_model_helpers_test.rb @@ -0,0 +1,16 @@ +require 'test_helper' + +class ContextModelHelpersTest < ActiveSupport::TestCase + include ContextModelHelpers + + def test_context_models_are_explicitly_allowlisted + assert_equal Unit, send(:context_class_for, 'units') + assert_equal TaskDefinition, send(:context_class_for, 'task_definitions') + end + + def test_arbitrary_constants_cannot_be_selected + assert_raises(KeyError) do + send(:context_class_for, 'Kernel') + end + end +end diff --git a/test/models/notification_discussion_request_test.rb b/test/models/notification_discussion_request_test.rb new file mode 100644 index 0000000000..7a9c24b27c --- /dev/null +++ b/test/models/notification_discussion_request_test.rb @@ -0,0 +1,161 @@ +require 'test_helper' +require 'minitest/mock' +require 'tempfile' + +# EN-V08: OnTrack has no discussion booking record. This proposal notifies the +# student when a tutor raises the audio discussion request that exists today. +class NotificationDiscussionRequestTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task = @project.task_for_task_definition(@task_definition) + @student = @project.student + @tutor = @project.tutor_for(@task_definition) + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def build_wav_upload + sample_rate = 8_000 + samples = Array.new(800, 0).pack('s<*') + header = [ + 'RIFF', 36 + samples.bytesize, 'WAVE', + 'fmt ', 16, 1, 1, sample_rate, sample_rate * 2, 2, 16, + 'data', samples.bytesize + ].pack('A4VA4A4VvvVVvvA4V') + + tempfile = Tempfile.new(['discussion-request', '.wav']) + tempfile.binmode + tempfile.write(header) + tempfile.write(samples) + tempfile.rewind + + { + 'filename' => 'discussion-request.wav', + 'type' => 'audio/wav', + 'tempfile' => tempfile + } + end + + def with_audio_uploads(count) + uploads = Array.new(count) { build_wav_upload } + yield uploads + ensure + uploads&.each do |upload| + upload['tempfile'].close! + rescue Errno::ENOENT + upload['tempfile'].close + end + end + + def notification_target + DiscussionComment.new(recipient: @student) + end + + def test_multiple_audio_prompts_create_one_notification_after_upload + discussion = nil + + with_audio_uploads(2) do |uploads| + assert_difference 'Notification.count', 1 do + discussion = @task.add_discussion_comment(@tutor, uploads) + end + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert discussion.persisted? + assert_equal 2, discussion.number_of_prompts + assert_equal @student, notification.user + assert_equal 'feedback', notification.notification_type + assert_equal 'discussion_request_created', notification.event + assert_equal 'A discussion prompt is ready for you.', notification.message + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}/feedback" + ) + + # Email is queued rather than sent inline since EN-F03. + NotificationEmailJob.drain + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_feedback_preference_suppresses_every_channel + @student.update!(receive_feedback_notifications: false) + + assert_no_difference 'Notification.count' do + @task.send(:notify_discussion_request_recipient, notification_target) + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_email_uses_the_event_template_without_assessment_content + private_task_name = 'PRIVATE-TASK-NAME-7429' + private_unit_name = 'PRIVATE-UNIT-NAME-7429' + private_tutor_name = 'PrivateTutor7429 SensitiveName7429' + + @task_definition.update!(name: private_task_name) + @unit.update!(name: private_unit_name) + @tutor.update!(first_name: 'PrivateTutor7429', last_name: 'SensitiveName7429') + + @task.send(:notify_discussion_request_recipient, notification_target) + NotificationEmailJob.drain + + body = delivered_body + + assert_not_empty body, 'guard: the multipart email body must be readable' + assert_includes body, 'The prompt is not included in this email' + assert_includes body, 'feedback notifications are turned on' + assert_not_includes body, private_task_name + assert_not_includes body, private_unit_name + assert_not_includes body, private_tutor_name + end + + def test_failed_audio_attachment_does_not_send_a_notification + invalid = Tempfile.new(['invalid-discussion-request', '.wav']) + invalid.write('not audio') + invalid.rewind + upload = { + 'filename' => 'invalid-discussion-request.wav', + 'type' => 'audio/wav', + 'tempfile' => invalid + } + + assert_no_difference 'Notification.count' do + assert_raises RuntimeError do + @task.add_discussion_comment(@tutor, [upload]) + end + end + + assert_empty ActionMailer::Base.deliveries + ensure + invalid&.close! + end + + def test_notification_failure_does_not_stop_the_request_being_created + discussion = nil + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification failed' } do + with_audio_uploads(1) do |uploads| + discussion = @task.add_discussion_comment(@tutor, uploads) + end + end + + assert_not_nil discussion + assert discussion.persisted? + assert_equal @student, discussion.recipient + end +end diff --git a/test/models/notification_extension_test.rb b/test/models/notification_extension_test.rb new file mode 100644 index 0000000000..c9fac274eb --- /dev/null +++ b/test/models/notification_extension_test.rb @@ -0,0 +1,182 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-E03: assessing an extension request notifies the student. +class NotificationExtensionTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + EXTENSION_REQUEST_TEXT = + 'Private extension request text that must stay inside OnTrack.'.freeze + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task_definition.update!(due_date: @task_definition.target_date + 2.weeks) + @task = @project.task_for_task_definition(@task_definition) + + @student = @project.student + @tutor = @project.tutor_for(@task_definition) + + # Prevent the request being assessed automatically when it is created. + @unit.update!(auto_apply_extension_before_deadline: false) + end + + def create_extension_request + @task.apply_for_extension( + @student, + EXTENSION_REQUEST_TEXT, + 1 + ) + end + + def delivered_parts + mail = ActionMailer::Base.deliveries.last + + { + html: mail&.html_part&.body&.decoded.to_s, + text: mail&.text_part&.body&.decoded.to_s + } + end + + def test_granted_extension_notifies_student_with_new_date + extension = create_extension_request + + assert_difference 'Notification.count', 1 do + extension.assess_extension(@tutor, true) + end + NotificationEmailJob.drain + + extension.reload + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'extension', notification.notification_type + assert_equal 'extension_assessed', notification.event + push = assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}" + ) + assert_not_includes notification.message, EXTENSION_REQUEST_TEXT + assert_not_includes push['body'], EXTENSION_REQUEST_TEXT + + assert extension.extension_granted + assert_includes notification.message, 'Extension granted' + assert_includes( + notification.message, + @task.reload.due_date.strftime('%a %b %e') + ) + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + + parts = delivered_parts + + assert_not_empty parts[:html] + assert_not_empty parts[:text] + + assert_includes parts[:html], notification.message + assert_includes parts[:text], notification.message + + assert_includes parts[:html], notification.link + assert_includes parts[:text], notification.link + end + + def test_denied_extension_notifies_student + extension = create_extension_request + + assert_difference 'Notification.count', 1 do + extension.assess_extension(@tutor, false) + end + NotificationEmailJob.drain + + extension.reload + notification = Notification.recent_first.first + + assert_not extension.extension_granted + + assert_equal @student, notification.user + assert_equal 'extension', notification.notification_type + assert_equal 'extension_assessed', notification.event + assert_equal 'Extension rejected', notification.message + + assert_equal 1, ActionMailer::Base.deliveries.count + + parts = delivered_parts + + assert_includes parts[:html], 'Extension rejected' + assert_includes parts[:text], 'Extension rejected' + end + + def test_already_assessed_extension_does_not_send_another_notification + extension = create_extension_request + + extension.assess_extension(@tutor, false) + + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + assert_no_difference 'Notification.count' do + result = extension.assess_extension(@tutor, true) + + assert_equal false, result + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_deadline_error_does_not_send_notification + extension = create_extension_request + + ActionMailer::Base.deliveries.clear + + @task.stub :can_apply_for_extension?, false do + assert_no_difference 'Notification.count' do + extension.assess_extension(@tutor, true) + end + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_failed_grant_does_not_assess_or_notify_student + extension = create_extension_request + task = extension.task + original_extensions = task.extensions + + task.stub :can_apply_for_extension?, true do + task.stub :grant_extension, false do + assert_no_difference 'Notification.count' do + result = extension.assess_extension(@tutor, true) + + assert_equal false, result + end + end + end + + assert_empty ActionMailer::Base.deliveries + assert_includes extension.errors[:extension], 'could not be applied' + assert_not extension.assessed? + assert_not extension.extension_granted + assert_equal original_extensions, task.reload.extensions + + extension.reload + assert_not extension.assessed? + assert_not extension.extension_granted + end + + def test_extension_notification_uses_event_specific_templates + extension = create_extension_request + + extension.assess_extension(@tutor, false) + NotificationEmailJob.drain + + parts = delivered_parts + + assert_includes parts[:html], 'Your extension request has been assessed' + assert_includes parts[:text], 'Your extension request has been assessed' + end +end diff --git a/test/models/notification_group_test.rb b/test/models/notification_group_test.rb new file mode 100644 index 0000000000..5b02e14a47 --- /dev/null +++ b/test/models/notification_group_test.rb @@ -0,0 +1,177 @@ +require 'test_helper' +require 'minitest/mock' +require 'tempfile' + +# EN-V05: notify only the affected student when group membership changes. +class NotificationGroupTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @group = FactoryBot.create(:group, unit: @project.unit) + @student = @project.student + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_adding_a_member_notifies_only_that_student + assert_difference 'Notification.count', 1 do + @group.add_member(@project) + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'general', notification.notification_type + assert_equal 'group_membership_changed', notification.event + assert_equal( + "You have been added to group #{@group.name} in #{@project.unit.code}.", + notification.message + ) + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/groups", + expected_body: 'Your group membership changed.' + ) + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + + # Confirms the event-specific template is being used. + assert_includes delivered_body, 'group membership changed' + assert_includes delivered_body, notification.message + end + + def test_removing_a_member_notifies_that_student + @group.add_member(@project) + + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + assert_difference 'Notification.count', 1 do + @group.remove_member(@project) + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'general', notification.notification_type + assert_equal 'group_membership_changed', notification.event + assert_includes notification.message, 'removed from' + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_other_group_members_are_not_notified + other_project = FactoryBot.create(:project, unit: @project.unit) + + @group.add_member(other_project) + + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + assert_difference 'Notification.count', 1 do + @group.add_member(@project) + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_not_equal other_project.student, notification.user + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_switch_to_tutorial_sends_tutorial_changes_without_leave_then_join_notifications + unit = FactoryBot.create( + :unit, + group_sets: 1, + groups: [{ gs: 0, students: 0 }] + ) + + group_set = unit.group_sets.first + group_set.update!( + keep_groups_in_same_class: true, + allow_students_to_manage_groups: true + ) + + group = group_set.groups.first + + project_one = group.tutorial.projects.first + project_two = group.tutorial.projects.last + + group.add_member(project_one) + group.add_member(project_two) + + new_tutorial = FactoryBot.create(:tutorial, unit: unit, campus: nil) + + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + assert_difference -> { Notification.where(event: 'tutorial_changed').count }, 2 do + assert_no_difference -> { Notification.where(event: 'group_membership_changed').count } do + group.switch_to_tutorial(new_tutorial) + end + end + NotificationEmailJob.drain + + tutorial_notifications = Notification.where(event: 'tutorial_changed').recent_first.limit(2) + + assert_equal( + [project_one.student.id, project_two.student.id].sort, + tutorial_notifications.map(&:user_id).sort + ) + assert_equal 2, ActionMailer::Base.deliveries.count + assert_equal( + [project_one.student.email, project_two.student.email].sort, + ActionMailer::Base.deliveries.flat_map(&:to).sort + ) + end + + def test_notification_failure_does_not_stop_membership_change + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification failed' } do + assert_nothing_raised do + @group.add_member(@project) + end + end + + assert_includes @group.reload.projects, @project + end + + def test_bulk_csv_import_adds_member_without_notification + Tempfile.create(['student-groups', '.csv']) do |file| + file.write("group_name,username\n#{@group.name},#{@student.username}\n") + file.flush + + notification_calls = 0 + + NotificationService.stub :notify, ->(**_kwargs) { notification_calls += 1 } do + result = @project.unit.import_student_groups_from_csv( + @group.group_set, + file.path + ) + + assert_empty result[:errors], result.inspect + assert_empty result[:ignored], result.inspect + assert_equal 1, result[:success].count, result.inspect + end + + assert_equal 0, notification_calls + end + + assert_includes @group.reload.projects, @project + end +end diff --git a/test/models/notification_new_task_test.rb b/test/models/notification_new_task_test.rb new file mode 100644 index 0000000000..88541d86cd --- /dev/null +++ b/test/models/notification_new_task_test.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' + +# EN-V02: newly available tasks notify eligible students. +class NotificationNewTaskTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 0, + campus_count: 1, + active: true, + start_date: Time.zone.now - 1.week, + end_date: Time.zone.now + 12.weeks + ) + + @campus = Campus.first + + @student = FactoryBot.create( + :user, + :student, + receive_task_notifications: true + ) + + @project = FactoryBot.create( + :project, + unit: @unit, + campus: @campus, + user: @student, + enrolled: true, + target_grade: 2 + ) + + @task_definition = FactoryBot.create( + :task_definition, + unit: @unit, + outcome_count: 0, + target_grade: 1, + start_date: Time.zone.now - 1.day, + target_date: Time.zone.now + 1.week, + due_date: Time.zone.now + 2.weeks + ) + end + + def run_job + NewTaskAvailableNotificationJob.new.perform(@task_definition.id) + NotificationEmailJob.drain + end + + def event_notifications + Notification.where(event: 'new_task_available') + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + if mail.multipart? + mail.parts.map { |part| part.body.decoded }.join("\n") + else + mail.body.decoded + end + end + + def test_available_task_notifies_eligible_student + assert_difference 'Notification.count', 1 do + assert_no_difference 'Task.count' do + run_job + end + end + + notification = event_notifications.last + + assert_equal @student, notification.user + assert_equal 'task', notification.notification_type + assert_equal 'new_task_available', notification.event + + assert_equal( + "A new task is available: #{@task_definition.abbreviation} in #{@unit.code}.", + notification.message + ) + + assert_equal( + "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}", + notification.link + ) + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}" + ) + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + + body = delivered_body + + assert_not_empty body + assert_includes body, 'A new task is now available' + assert_includes body, @task_definition.abbreviation + end + + def test_fans_out_to_each_eligible_student + second_student = FactoryBot.create( + :user, + :student, + receive_task_notifications: true + ) + + FactoryBot.create( + :project, + unit: @unit, + campus: @campus, + user: second_student, + enrolled: true, + target_grade: 2 + ) + + assert_difference 'Notification.count', 2 do + run_job + end + + recipients = event_notifications.includes(:user).map(&:user) + + assert_includes recipients, @student + assert_includes recipients, second_student + assert_equal 2, ActionMailer::Base.deliveries.count + end + + def test_student_with_task_notifications_disabled_is_not_notified + @student.update!(receive_task_notifications: false) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_unenrolled_student_is_not_notified + @project.update!(enrolled: false) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_student_below_task_target_grade_is_not_notified + @project.update!(target_grade: 0) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_inactive_unit_does_not_send_notifications + @unit.update!(active: false) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_future_effective_student_start_date_is_not_notified + @unit.update!(allow_flexible_dates: true) + + task = @project.task_for_task_definition(@task_definition) + + task.update!( + target_start_date: Time.zone.now + 2.days + ) + + assert_operator task.local_start_date.to_date, :>, Time.zone.today + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_notification_failure_makes_job_fail_for_retry + notification_failure = lambda do |_notification| + raise StandardError, 'temporary notification failure' + end + + NotificationService.stub(:deliver, notification_failure) do + error = assert_raises(RuntimeError) do + run_job + end + + assert_includes error.message, @project.id.to_s + end + + notification = event_notifications.find_by!(user: @student) + assert_nil notification.delivered_at + + assert_no_difference 'Notification.count' do + run_job + end + + assert_not_nil notification.reload.delivered_at + assert_equal 1, ActionMailer::Base.deliveries.count + end + + def test_job_has_limited_retries + assert_equal( + 3, + NewTaskAvailableNotificationJob.get_sidekiq_options['retry'] + ) + end + + def test_running_fan_out_twice_does_not_duplicate_notification + run_job + + assert_equal 1, event_notifications.count + assert_equal 1, ActionMailer::Base.deliveries.count + + assert_no_difference 'Notification.count' do + run_job + end + + assert_equal 1, event_notifications.count + assert_equal 1, ActionMailer::Base.deliveries.count + end + + def test_delivery_rechecks_a_stale_project_after_withdrawal + stale_project = Project.find(@project.id) + @project.update!(enrolled: false) + + assert_no_difference 'Notification.count' do + NewTaskAvailableNotificationJob.deliver(stale_project, @task_definition) + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_renaming_a_task_does_not_send_a_second_availability_notification + run_job + @task_definition.update!(abbreviation: "RENAMED#{SecureRandom.hex(3)}") + + assert_no_difference 'Notification.count' do + run_job + end + + assert_equal 1, ActionMailer::Base.deliveries.count + end + + def test_reusing_an_abbreviation_for_a_new_task_still_notifies + reused_abbreviation = @task_definition.abbreviation + run_job + @task_definition.update!(abbreviation: "RETIRED#{SecureRandom.hex(3)}") + + replacement = FactoryBot.create( + :task_definition, + unit: @unit, + outcome_count: 0, + abbreviation: reused_abbreviation, + target_grade: 1, + start_date: 1.day.ago, + target_date: 1.week.from_now + ) + + assert_difference 'Notification.count', 1 do + NewTaskAvailableNotificationJob.new.perform(replacement.id) + end + # This call bypasses run_job, so it has to drain the mail queue itself. + NotificationEmailJob.drain + + assert_equal 2, ActionMailer::Base.deliveries.count + end +end diff --git a/test/models/notification_portfolio_test.rb b/test/models/notification_portfolio_test.rb new file mode 100644 index 0000000000..6743619b9c --- /dev/null +++ b/test/models/notification_portfolio_test.rb @@ -0,0 +1,192 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-V07: a newly accepted manual portfolio submission sends one receipt to +# the submitting student. +class NotificationPortfolioTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::PushNotificationHelper + + def app + Rails.application + end + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @project.campus.update!(timezone: 'Australia/Melbourne') + @student = @project.student + + add_auth_header_for(user: @student) + end + + def submit_portfolio(value: true) + # Some receipt assertions freeze time. Mint the request token inside that + # clock so the authentication expiry is evaluated against the same instant. + add_auth_header_for(user: @student) + + put_json( + "/api/projects/#{@project.id}", + id: @project.id, + compile_portfolio: value + ) + + assert_equal 200, last_response.status, last_response.body + + # Email is queued rather than sent inline since EN-F03. Draining here keeps + # every deliveries assertion in this file reading the way it did before, + # and it is the same shape as run_job in the other notification tests. + NotificationEmailJob.drain + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_a_new_portfolio_submission_sends_one_receipt_to_the_student + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + assert_difference 'Notification.count', 1 do + submit_portfolio + end + end + NotificationEmailJob.drain + + notification = Notification.find_by!(user: @student, event: 'portfolio_received') + + assert_equal @student, notification.user + assert_equal 'portfolio', notification.notification_type + assert_equal 'portfolio_received', notification.event + assert_equal( + "#{Doubtfire::Application.config.institution[:product_name]} received your " \ + 'portfolio submission at 23 August 2026 at 10:34 PM AEST (UTC+10:00).', + notification.message + ) + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard", + expected_body: 'Your portfolio submission was received.' + ) + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + assert_equal( + "#{Doubtfire::Application.config.institution[:product_name]}: New notification", + ActionMailer::Base.deliveries.last.subject + ) + end + + def test_the_receipt_uses_the_event_template_and_excludes_assessment_content + assessment_content = 'PRIVATE-ASSESSMENT-RATIONALE-7429' + @project.update!(grade_rationale: assessment_content, submitted_grade: 3) + + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + submit_portfolio + end + NotificationEmailJob.drain + + body = delivered_body + + assert_not_empty body, 'guard: the body must be readable or the privacy assertions prove nothing' + assert_includes body, '23 August 2026 at 10:34 PM AEST (UTC+10:00)' + assert_includes body, 'This receipt confirms when the submission was received' + assert_includes body, 'It does not confirm an assessment outcome' + assert_not_includes body, assessment_content + end + + def test_portfolio_preference_suppresses_every_notification_channel + @student.update!(receive_portfolio_notifications: false) + + assert_no_difference 'Notification.count' do + submit_portfolio + end + + assert_empty ActionMailer::Base.deliveries + assert @project.reload.compile_portfolio? + assert_not_nil @project.portfolio_submission_date + end + + def test_retrying_a_pending_manual_submission_does_not_send_a_second_receipt + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + submit_portfolio + end + + original_submission_date = @project.reload.portfolio_submission_date + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + travel_to Time.zone.parse('2026-08-23 12:39:00 UTC') do + assert_no_difference 'Notification.count' do + submit_portfolio + end + end + + assert_empty ActionMailer::Base.deliveries + assert_equal original_submission_date, @project.reload.portfolio_submission_date + end + + def test_a_later_resubmission_receives_a_new_receipt + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + submit_portfolio + end + NotificationEmailJob.drain + + first_submission_date = @project.reload.portfolio_submission_date + @project.update!(compile_portfolio: false) + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + travel_to Time.zone.parse('2026-08-24 01:15:00 UTC') do + assert_difference 'Notification.count', 1 do + submit_portfolio + end + end + NotificationEmailJob.drain + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_operator @project.reload.portfolio_submission_date, :>, first_submission_date + end + + def test_a_manual_submission_replaces_a_pending_auto_generated_portfolio + @project.update!( + compile_portfolio: true, + portfolio_auto_generated: true, + portfolio_submission_date: nil + ) + + assert_difference 'Notification.count', 1 do + submit_portfolio + end + NotificationEmailJob.drain + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_not @project.reload.portfolio_auto_generated? + assert_not_nil @project.portfolio_submission_date + end + + def test_cancelling_portfolio_generation_does_not_send_a_receipt + assert_no_difference 'Notification.count' do + submit_portfolio(value: false) + end + + assert_empty ActionMailer::Base.deliveries + assert_nil @project.reload.portfolio_submission_date + end + + def test_a_notification_failure_does_not_reject_the_submission + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification failed' } do + assert_nothing_raised do + submit_portfolio + end + end + + assert @project.reload.compile_portfolio? + assert_not_nil @project.portfolio_submission_date + end +end diff --git a/test/models/notification_task_comment_test.rb b/test/models/notification_task_comment_test.rb new file mode 100644 index 0000000000..abb5b5c951 --- /dev/null +++ b/test/models/notification_task_comment_test.rb @@ -0,0 +1,137 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-E01: posting a task comment notifies the other party. +class NotificationTaskCommentTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task = @project.task_for_task_definition(@task_definition) + @student = @project.student + @tutor = @project.tutor_for(@task_definition) + end + + # The notification email is multipart, and Mail::Body#to_s is empty for a + # multipart body. Reading it the naive way makes every refute_includes pass + # for the wrong reason, so decode the parts instead. + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_a_tutor_comment_notifies_the_student + assert_difference 'Notification.count', 1 do + @task.add_text_comment(@tutor, 'Have a look at question three.') + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'feedback', notification.notification_type + assert_equal 'task_comment_created', notification.event + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}/feedback" + ) + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_a_student_comment_notifies_the_tutor + assert_difference 'Notification.count', 1 do + @task.add_text_comment(@student, 'I am stuck on question three.') + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + # The recipient is the other party, never the person who commented. + assert_equal @tutor, notification.user + assert_not_equal @student, notification.user + assert_equal [@tutor.email], ActionMailer::Base.deliveries.last.to + end + + def test_no_notification_when_the_feedback_preference_is_off + @student.update!(receive_feedback_notifications: false) + + assert_no_difference 'Notification.count' do + @task.add_text_comment(@tutor, 'You will not be told about this.') + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_the_comment_text_is_not_in_the_notification_or_the_email + secret = 'Please do not put this sentence in an email.' + @task.add_text_comment(@tutor, secret) + NotificationEmailJob.drain + + notification = Notification.recent_first.first + body = delivered_body + + push = parsed_push_notification(notification) + + assert_not_empty body, 'guard: the body must be readable or this test proves nothing' + assert_not_includes notification.message, secret + assert_not_includes body, secret + assert_not_includes push['body'], secret + end + + def test_the_message_names_the_commenter_and_the_task + @task.add_text_comment(@tutor, 'Named check.') + + message = Notification.recent_first.first.message + + assert_includes message, @tutor.name + assert_includes message, @task_definition.abbreviation + end + + def test_the_link_points_at_the_task_feedback_on_the_student_dashboard + @task.add_text_comment(@tutor, 'Link check.') + + assert_equal( + "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}/feedback", + Notification.recent_first.first.link + ) + end + + def test_the_event_specific_template_is_used_instead_of_the_generic_one + @task.add_text_comment(@tutor, 'Template check.') + NotificationEmailJob.drain + + body = delivered_body + + # Wording that only exists in task_comment_created.*.erb. If the mailer ever + # falls back to single_notification.*.erb this fails. + assert_includes body, 'The comment is not included in this email' + end + + def test_a_notification_failure_does_not_stop_the_comment_being_posted + comment = nil + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification exploded' } do + comment = @task.add_text_comment(@tutor, 'This must still be saved.') + end + + assert_not_nil comment + assert comment.persisted? + assert_equal 'This must still be saved.', comment.comment + end + + def test_no_notification_and_no_error_when_there_is_no_recipient + comment = TaskComment.new(recipient: nil) + + assert_no_difference 'Notification.count' do + assert_nothing_raised { @task.notify_comment_recipient(comment) } + end + end +end diff --git a/test/models/notification_task_status_test.rb b/test/models/notification_task_status_test.rb new file mode 100644 index 0000000000..b9a96d7528 --- /dev/null +++ b/test/models/notification_task_status_test.rb @@ -0,0 +1,154 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-E02: a staff status change notifies the student. A student's own action +# does not. +class NotificationTaskStatusTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task = @project.task_for_task_definition(@task_definition) + @student = @project.student + @tutor = @unit.main_convenor_user + + # Put the task where a tutor can mark it, then start from a clean inbox so + # the setup's own status comment does not count towards the assertions. + @task.update!(task_status: TaskStatus.ready_for_feedback) + @task.add_status_comment(@student, TaskStatus.ready_for_feedback) + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + end + + # The notification email is multipart, and Mail::Body#to_s is empty for a + # multipart body. Reading it the naive way makes every refute pass for the + # wrong reason, so decode the parts instead. + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_a_staff_status_change_notifies_the_student + assert_difference 'Notification.count', 1 do + assert @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'task', notification.notification_type + assert_equal 'task_status_changed', notification.event + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}" + ) + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_a_students_own_action_notifies_nobody + assert_no_difference 'Notification.count' do + assert @task.trigger_transition(trigger: 'working_on_it', by_user: @student) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_an_unchanged_status_notifies_nobody + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + # Re-applying the same status is a no-op: no change, no notification. + assert_no_difference 'Notification.count' do + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_no_notification_when_the_task_preference_is_off + @student.update!(receive_task_notifications: false) + + assert_no_difference 'Notification.count' do + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_the_status_value_is_not_in_the_notification_or_the_email + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + NotificationEmailJob.drain + + notification = Notification.recent_first.first + body = delivered_body + + push = parsed_push_notification(notification) + + assert_not_empty body, 'guard: the body must be readable or this test proves nothing' + assert_not_includes notification.message, 'Discuss' + assert_not_includes body, 'Discuss' + assert_not_includes push['body'], 'Discuss' + end + + def test_the_message_names_the_actor_and_the_task + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + + message = Notification.recent_first.first.message + + assert_includes message, @tutor.name + assert_includes message, @task_definition.abbreviation + end + + def test_the_link_points_at_the_task_on_the_student_dashboard + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + + assert_equal( + "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}", + Notification.recent_first.first.link + ) + end + + def test_the_event_specific_template_is_used_instead_of_the_generic_one + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + NotificationEmailJob.drain + + body = delivered_body + + # Wording that only exists in task_status_changed.*.erb. If the mailer ever + # falls back to single_notification.*.erb this fails. + assert_includes body, 'The new status is not included in this email' + end + + def test_bulk_marking_still_notifies_one_per_task + # This event ignores the bulk: flag on purpose (see the event doc): a bulk + # mark still notifies. One call, one task, one email. + assert_difference 'Notification.count', 1 do + assert @task.trigger_transition(trigger: 'discuss', by_user: @tutor, bulk: true) + end + NotificationEmailJob.drain + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_a_notification_failure_does_not_stop_the_transition + result = nil + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification exploded' } do + result = @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + + assert result, 'the transition must still succeed' + assert_equal TaskStatus.discuss, @task.reload.task_status + end +end diff --git a/test/models/notification_task_submitted_test.rb b/test/models/notification_task_submitted_test.rb new file mode 100644 index 0000000000..7bb1634129 --- /dev/null +++ b/test/models/notification_task_submitted_test.rb @@ -0,0 +1,161 @@ +require 'test_helper' +require 'cgi' +require 'minitest/mock' + +# EN-V06: a student submission notifies the responsible tutor once. +class NotificationTaskSubmittedTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task_definition.update!( + start_date: 1.week.ago, + target_date: 1.week.from_now + ) + @task = @project.task_for_task_definition(@task_definition) + @student = @project.student + @tutor = @project.tutor_for(@task_definition) + end + + def delivered_parts + mail = ActionMailer::Base.deliveries.last + + { + html: mail&.html_part&.body&.decoded.to_s, + text: mail&.text_part&.body&.decoded.to_s + } + end + + def submit_for_marking(**options) + @task.trigger_transition( + trigger: 'ready_for_feedback', + by_user: @student, + **options + ) + end + + def test_ready_for_marking_notifies_the_tutor_once_without_a_status_change_event + assert_difference 'Notification.count', 1 do + assert submit_for_marking + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal TaskStatus.ready_for_feedback, @task.reload.task_status + assert_equal @tutor, notification.user + assert_equal 'task', notification.notification_type + assert_equal 'task_submitted', notification.event + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@tutor.email], ActionMailer::Base.deliveries.last.to + + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}", + expected_body: 'A task is ready for marking.' + ) + end + + def test_message_and_templates_use_the_approved_tutor_facing_copy + submit_for_marking + NotificationEmailJob.drain + + notification = Notification.recent_first.first + parts = delivered_parts + product_name = Doubtfire::Application.config.institution[:product_name] + expected_message = + "#{@student.name} submitted #{@task_definition.name} for marking in #{product_name}." + + assert_equal expected_message, notification.message + assert_equal "#{product_name}: New notification", ActionMailer::Base.deliveries.last.subject + + parts.each_value do |body| + assert_not_empty body + assert_includes body, "Hi #{@tutor.first_name}" + assert_includes body, 'The submission and any assessment content are not included in this email.' + assert_includes body, notification.link + assert_includes body, '/edit_profile' + end + + assert_includes parts[:text], expected_message + assert_includes parts[:html], CGI.escapeHTML(expected_message) + end + + def test_missing_tutor_is_safely_ignored + @task.update!(task_status: TaskStatus.ready_for_feedback) + + @project.stub :tutor_for, nil do + assert_no_difference 'Notification.count' do + assert_nothing_raised do + @task.notify_tutor_of_task_submission( + @student, + :student, + TaskStatus.not_started.id, + false + ) + end + end + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_tutor_task_preference_suppresses_the_notification + @tutor.update!(receive_task_notifications: false) + + assert_no_difference 'Notification.count' do + assert submit_for_marking + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_repeating_ready_for_marking_does_not_notify_again + assert submit_for_marking + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + assert_no_difference 'Notification.count' do + assert submit_for_marking + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_internal_group_transition_does_not_amplify_the_notification + assert_no_difference 'Notification.count' do + assert submit_for_marking(group_transition: true) + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_a_tutor_ready_for_feedback_transition_only_raises_the_existing_status_event + assert_difference 'Notification.count', 1 do + assert @task.trigger_transition(trigger: 'ready_for_feedback', by_user: @tutor) + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal 'task_status_changed', notification.event + assert_equal @student, notification.user + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_notification_failure_does_not_stop_the_submission_transition + result = nil + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification exploded' } do + result = submit_for_marking + end + + assert result, 'the submission transition must still succeed' + assert_equal TaskStatus.ready_for_feedback, @task.reload.task_status + end +end diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb new file mode 100644 index 0000000000..fc995cfe40 --- /dev/null +++ b/test/models/notification_test.rb @@ -0,0 +1,30 @@ +require 'test_helper' + +class NotificationTest < ActiveSupport::TestCase + def test_the_factory_builds_a_valid_notification_for_every_category + Notification::TYPES.each do |type| + notification = FactoryBot.create(:notification, type.to_sym) + + assert notification.persisted?, "a #{type} notification did not save" + assert_equal type, notification.notification_type + assert_equal "#{type}_event", notification.event + assert notification.message.present? + end + end + + def test_an_unread_notification_is_in_the_unread_scope + notification = FactoryBot.create(:notification, :unread) + + assert_nil notification.read_at + assert_not notification.read? + assert_includes Notification.unread, notification + end + + def test_a_read_notification_is_out_of_the_unread_scope + notification = FactoryBot.create(:notification, :read) + + assert_not_nil notification.read_at + assert notification.read? + assert_not_includes Notification.unread, notification + end +end diff --git a/test/models/notification_tutorial_test.rb b/test/models/notification_tutorial_test.rb new file mode 100644 index 0000000000..77b37ec864 --- /dev/null +++ b/test/models/notification_tutorial_test.rb @@ -0,0 +1,155 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-V04: notify only the affected student when an existing tutorial enrolment moves. +class NotificationTutorialTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @student = @project.student + @old_tutorial = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + abbreviation: 'OLD_TUT', + meeting_day: 'Monday', + meeting_time: '09:00' + ) + @new_tutorial = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + abbreviation: 'NEW_TUT', + meeting_day: 'Tuesday', + meeting_time: '14:30' + ) + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_moving_an_existing_enrolment_notifies_only_the_affected_student + @project.enrol_in(@old_tutorial) + other_project = FactoryBot.create(:project, unit: @unit, campus: @project.campus) + + ActionMailer::Base.deliveries.clear + + assert_difference 'Notification.count', 1 do + @project.enrol_in(@new_tutorial) + end + NotificationEmailJob.drain + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_not_equal other_project.student, notification.user + assert_equal 'general', notification.notification_type + assert_equal 'tutorial_changed', notification.event + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard", + expected_body: 'Your tutorial details changed.' + ) + end + + def test_message_and_templates_name_only_the_new_tutorial_schedule + @project.enrol_in(@old_tutorial) + + ActionMailer::Base.deliveries.clear + @project.enrol_in(@new_tutorial) + NotificationEmailJob.drain + + notification = Notification.recent_first.first + body = delivered_body + + assert_not_empty body, 'guard: the email body must be readable' + + [notification.message, body].each do |content| + assert_includes content, @new_tutorial.abbreviation + assert_includes content, @new_tutorial.meeting_day + assert_includes content, @new_tutorial.meeting_time + assert_not_includes content, @old_tutorial.abbreviation + assert_not_includes content, @old_tutorial.meeting_day + assert_not_includes content, @old_tutorial.meeting_time + end + + mail = ActionMailer::Base.deliveries.last + assert mail.multipart? + assert_includes mail.parts.map(&:mime_type), 'text/plain' + assert_includes mail.parts.map(&:mime_type), 'text/html' + assert_includes body, 'Your tutorial has changed' + end + + def test_first_tutorial_enrolment_does_not_notify + assert_no_difference 'Notification.count' do + @project.enrol_in(@new_tutorial) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_selecting_the_same_tutorial_again_does_not_notify + @project.enrol_in(@old_tutorial) + + ActionMailer::Base.deliveries.clear + + assert_no_difference 'Notification.count' do + @project.enrol_in(@old_tutorial) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_collapsing_multiple_stream_enrolments_does_not_notify + stream_one = FactoryBot.create(:tutorial_stream, unit: @unit) + stream_two = FactoryBot.create(:tutorial_stream, unit: @unit) + streamed_tutorial_one = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + tutorial_stream: stream_one + ) + streamed_tutorial_two = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + tutorial_stream: stream_two + ) + + @project.enrol_in(streamed_tutorial_one) + @project.enrol_in(streamed_tutorial_two) + assert_equal 2, @project.tutorial_enrolments.count + + ActionMailer::Base.deliveries.clear + + assert_no_difference 'Notification.count' do + @project.enrol_in(@new_tutorial) + end + + assert_equal [@new_tutorial], @project.reload.tutorial_enrolments.map(&:tutorial) + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_notification_failure_does_not_stop_the_tutorial_move + @project.enrol_in(@old_tutorial) + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification failed' } do + assert_nothing_raised do + @project.enrol_in(@new_tutorial) + end + end + + assert_equal @new_tutorial, @project.reload.tutorial_enrolments.first.tutorial + 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/models/peer_progress_snapshot_test.rb b/test/models/peer_progress_snapshot_test.rb new file mode 100644 index 0000000000..9f25db3d9f --- /dev/null +++ b/test/models/peer_progress_snapshot_test.rb @@ -0,0 +1,300 @@ +require 'test_helper' + +class PeerProgressSnapshotTest < ActiveSupport::TestCase + setup do + @unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0 + ) + + @task_definition = create( + :task_definition, + unit: @unit, + target_grade: 0, + outcome_count: 0 + ) + end + + test 'is valid with the required aggregate fields' do + assert build_snapshot.valid? + end + + test 'belongs to its unit and task definition' do + snapshot = create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition + ) + + assert_equal @unit, snapshot.unit + assert_equal @task_definition, snapshot.task_definition + end + + test 'requires a calculation timestamp' do + snapshot = build_snapshot(calculated_at: nil) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:calculated_at], + "can't be blank" + ) + end + + test 'accepts a genuine zero percentage for a non-empty cohort' do + snapshot = build_snapshot( + submitted_percentage: 0, + cohort_size: 10 + ) + + assert snapshot.valid? + end + + test 'accepts a nil percentage for unavailable or suppressed data' do + suppressed = build_snapshot( + submitted_percentage: nil, + cohort_size: 3 + ) + + unavailable = build_snapshot( + submitted_percentage: nil, + cohort_size: 0 + ) + + assert suppressed.valid? + assert unavailable.valid? + end + + test 'rejects percentages outside zero to one hundred' do + below_zero = build_snapshot( + submitted_percentage: -0.01 + ) + + above_one_hundred = build_snapshot( + submitted_percentage: 100.01 + ) + + assert_not below_zero.valid? + assert_not above_one_hundred.valid? + end + + test 'requires a non-negative integer cohort size' do + negative = build_snapshot(cohort_size: -1) + decimal = build_snapshot(cohort_size: 2.5) + + assert_not negative.valid? + assert_not decimal.valid? + end + + test 'accepts an exact submitted count within the cohort' do + snapshot = build_snapshot( + submitted_count: 4, + cohort_size: 10 + ) + + assert snapshot.valid? + end + + test 'rejects an invalid exact submitted count' do + negative = build_snapshot(submitted_count: -1) + decimal = build_snapshot(submitted_count: 1.5) + above_cohort = build_snapshot( + submitted_count: 11, + cohort_size: 10 + ) + + assert_not negative.valid? + assert_not decimal.valid? + assert_not above_cohort.valid? + end + + test 'accepts complete internal status counts that sum to the cohort' do + snapshot = build_snapshot( + cohort_size: 10, + status_counts: empty_status_counts.merge( + 'not_started' => 6, + 'complete' => 4 + ) + ) + + assert snapshot.valid?, snapshot.errors.full_messages.to_sentence + end + + test 'persists lifecycle JSON as a hash on MariaDB compatible text columns' do + counts = empty_status_counts.merge('not_started' => 10) + snapshot = create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + cohort_size: 10, + submitted_count: 0, + status_counts: counts + ) + snapshot.reload + + assert_instance_of Hash, snapshot.status_counts + assert_equal counts, snapshot.status_counts + assert_equal 0, snapshot.submitted_count + end + + test 'rejects incomplete invalid or inconsistent internal status counts' do + missing = build_snapshot( + status_counts: empty_status_counts.except('redo') + ) + negative = build_snapshot( + status_counts: empty_status_counts.merge( + 'not_started' => 11, + 'redo' => -1 + ) + ) + wrong_total = build_snapshot( + status_counts: empty_status_counts.merge('not_started' => 9) + ) + + assert_not missing.valid? + assert_not negative.valid? + assert_not wrong_total.valid? + end + + test 'does not allow a percentage when cohort size is zero' do + snapshot = build_snapshot( + submitted_percentage: 0, + cohort_size: 0 + ) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:submitted_percentage], + 'must be blank when cohort size is zero' + ) + end + + test 'requires the task definition to belong to the same unit' do + other_unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0 + ) + + snapshot = build_snapshot(unit: other_unit) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:task_definition], + 'must belong to the same unit' + ) + end + + test 'requires a target grade enabled for the unit' do + snapshot = build_snapshot(target_grade: 99) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:target_grade], + 'must be enabled for the unit' + ) + end + + test 'requires the cohort grade to cover the task target grade' do + higher_grade_task = create( + :task_definition, + unit: @unit, + target_grade: 2, + outcome_count: 0 + ) + + snapshot = build_snapshot( + task_definition: higher_grade_task, + target_grade: 1 + ) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:target_grade], + 'must be at least the task definition target grade' + ) + end + + test 'enforces one snapshot per unit task and target grade' do + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: 0 + ) + + duplicate = build_snapshot(target_grade: 0) + + assert_not duplicate.valid? + + assert_includes( + duplicate.errors[:target_grade], + 'has already been taken' + ) + end + + test 'allows another target grade for the same unit and task' do + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: 0 + ) + + second_grade = build_snapshot(target_grade: 1) + + assert second_grade.valid?, + second_grade.errors.full_messages.to_sentence + end + + test 'database index rejects duplicate aggregate keys' do + snapshot = create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: 0 + ) + + duplicate = snapshot.dup + + assert_raises ActiveRecord::RecordNotUnique do + duplicate.save!(validate: false) + end + end + + test 'destroying a task definition destroys its snapshots' do + snapshot = create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition + ) + + snapshot_id = snapshot.id + + @task_definition.destroy! + + assert_not PeerProgressSnapshot.exists?(snapshot_id) + end + + private + + def build_snapshot(**overrides) + build( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + **overrides + ) + end + + def empty_status_counts + PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 0 } + end +end diff --git a/test/models/project_target_grade_changed_at_test.rb b/test/models/project_target_grade_changed_at_test.rb new file mode 100644 index 0000000000..3b20568da0 --- /dev/null +++ b/test/models/project_target_grade_changed_at_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'test_helper' +require Rails.root.join('db/migrate/20260824000002_ensure_target_grade_changed_at_default') + +class ProjectTargetGradeChangedAtTest < Minitest::Test + def teardown + Project.where(status: @insert_marker).delete_all if @insert_marker + EnsureTargetGradeChangedAtDefault.new.up + Project.reset_column_information + end + + def test_database_default_supports_old_writers + inserted_at = Time.current + @insert_marker = "target-grade-default-regression-#{object_id}" + + # Bypass Project's before_create callback on purpose. This matches an older + # application instance that does not know about target_grade_changed_at. + # rubocop:disable Rails/SkipsModelValidations + Project.insert_all!( + [ + { + status: @insert_marker, + created_at: inserted_at, + updated_at: inserted_at + } + ] + ) + # rubocop:enable Rails/SkipsModelValidations + + project = Project.find_by!(status: @insert_marker) + assert project.target_grade_changed_at + assert_operator project.target_grade_changed_at, :>=, inserted_at - 1.second + end + + def test_follow_up_migration_repairs_a_missing_default_and_is_idempotent + migration = EnsureTargetGradeChangedAtDefault.new + migration.change_column_default :projects, :target_grade_changed_at, nil + + assert_nil target_grade_changed_at_column.default_function + + migration.up + assert_current_timestamp_default + + migration.up + assert_current_timestamp_default + end + + private + + def assert_current_timestamp_default + value = target_grade_changed_at_column.default_function.to_s.delete(' ') + assert_match(/\Acurrent_timestamp(?:\(\d*\))?\z/i, value) + end + + def target_grade_changed_at_column + ActiveRecord::Base.connection.columns(:projects).find do |column| + column.name == 'target_grade_changed_at' + end + end +end diff --git a/test/models/push_subscription_test.rb b/test/models/push_subscription_test.rb new file mode 100644 index 0000000000..3ee1228187 --- /dev/null +++ b/test/models/push_subscription_test.rb @@ -0,0 +1,82 @@ +require 'test_helper' + +# The endpoint arrives from the browser and PushNotificationService later makes +# an outbound POST to it, so anything that is not a real push service URL has to +# be refused on the way in. +class PushSubscriptionTest < ActiveSupport::TestCase + setup do + @user = FactoryBot.create(:user, :student) + end + + def build_with(endpoint) + FactoryBot.build(:push_subscription, user: @user, endpoint: endpoint) + end + + # Every service we actually expect to see, so a future change to the list + # cannot quietly drop a browser. + ACCEPTED = [ + 'https://fcm.googleapis.com/fcm/send/abc123', + 'https://android.googleapis.com/gcm/send/abc123', + 'https://updates.push.services.mozilla.com/wpush/v2/abc123', + 'https://web.push.apple.com/abc123', + 'https://webcourier.push.apple.com/abc123', + 'https://par02p.notify.windows.com/w/?token=abc123', + 'https://wns2-by3p.push.services.microsoft.com/w/?token=abc123' + ].freeze + + ACCEPTED.each_with_index do |endpoint, index| + define_method("test_accepts_known_push_service_#{index}") do + subscription = build_with(endpoint) + + assert subscription.valid?, "#{endpoint} should be accepted but was rejected with #{subscription.errors.full_messages}" + end + end + + # The SSRF cases. Each of these is a host an attacker would want the api to + # make a request to on their behalf. + REJECTED = { + 'plain http' => 'http://fcm.googleapis.com/fcm/send/abc', + 'localhost' => 'https://localhost/fcm/send/abc', + 'loopback ip' => 'https://127.0.0.1/fcm/send/abc', + 'link local metadata' => 'https://169.254.169.254/latest/meta-data/', + 'private range' => 'https://10.0.0.5/internal', + 'the api container itself' => 'https://doubtfire-api:3000/api/users', + 'an arbitrary host' => 'https://example.com/push', + 'userinfo redirect trick' => 'https://fcm.googleapis.com@evil.example.com/push', + 'non standard port' => 'https://fcm.googleapis.com:8080/fcm/send/abc', + 'suffix lookalike' => 'https://evil-notify.windows.com/w/?token=abc', + 'apple suffix without boundary' => 'https://evilpush.apple.com/abc', + 'apple suffix followed by another domain' => 'https://web.push.apple.com.evil.example/abc', + 'bare apple parent domain' => 'https://push.apple.com/abc', + 'host substring lookalike' => 'https://fcm.googleapis.com.evil.example.com/push', + 'not a url at all' => 'not a url', + 'file scheme' => 'file:///etc/passwd' + }.freeze + + REJECTED.each do |name, endpoint| + define_method("test_rejects_#{name.tr(' ', '_')}") do + subscription = build_with(endpoint) + + assert_not subscription.valid?, "#{endpoint} (#{name}) should have been rejected" + assert_includes subscription.errors[:endpoint].join, 'recognised push service' + end + end + + def test_the_factory_endpoint_is_accepted + # Guards against the allowlist and the factory drifting apart, which would + # break every other push test at once and look like an unrelated failure. + assert FactoryBot.build(:push_subscription, user: @user).valid? + end + + def test_push_service_endpoint_predicate_handles_blank_input + assert_not PushSubscription.push_service_endpoint?(nil) + assert_not PushSubscription.push_service_endpoint?('') + end + + def test_an_endpoint_is_still_required + subscription = build_with(nil) + + assert_not subscription.valid? + assert_includes subscription.errors[:endpoint].join, "can't be blank" + end +end diff --git a/test/models/task_definition_test.rb b/test/models/task_definition_test.rb index 6312419108..c4939503f8 100644 --- a/test/models/task_definition_test.rb +++ b/test/models/task_definition_test.rb @@ -187,10 +187,20 @@ def test_group_tasks group_set.save! path = Rails.root.join('test_files', 'unit_csv_imports', 'import_group_tasks.csv') - u.import_tasks_from_csv File.new(path) + assert_difference( + -> { NewTaskAvailableNotificationJob.jobs.size }, + 1 + ) do + u.import_tasks_from_csv File.new(path) + end assert_equal 1, group_set.task_definitions.count assert_equal initial_count + 1, u.task_definitions.count + assert_not_nil group_set.task_definitions.first.new_task_notifications_from + + assert_no_difference -> { NewTaskAvailableNotificationJob.jobs.size } do + u.import_tasks_from_csv File.new(path) + end end def test_export_task_definitions_csv diff --git a/test/models/task_similarity_test.rb b/test/models/task_similarity_test.rb index 2ec972a113..501d9e31e1 100644 --- a/test/models/task_similarity_test.rb +++ b/test/models/task_similarity_test.rb @@ -273,7 +273,7 @@ def test_fetch_viewer_url get "/api/tasks/#{task.id}/similarities/#{sim.id}/viewer_url" assert_equal 200, last_response.status - assert last_response.body.include? "https://viewer.url" + assert_equal 'https://viewer.url', JSON.parse(last_response.body) add_auth_header_for(user: task.project.student) get "/api/tasks/#{task.id}/similarities/#{sim.id}/viewer_url" @@ -283,4 +283,155 @@ def test_fetch_viewer_url sim.destroy! task.destroy! end + + # A MOSS match that points at a task destroyed between the scan and this run + # used to raise ActiveRecord::RecordNotFound from Task.find and abort the whole + # import, losing every later match. find_by returns nil so the guard skips it + # and the remaining matches are still linked. + def test_moss_import_skips_a_missing_task_and_keeps_processing + unit = FactoryBot.create(:unit, with_students: false, stream_count: 0) + td = unit.task_definitions.first + td.update!(plagiarism_updated: true, plagiarism_warn_pct: 10) + + task_a = FactoryBot.create(:project, unit: unit).task_for_task_definition(td) + task_b = FactoryBot.create(:project, unit: unit).task_for_task_definition(td) + missing_id = Task.maximum(:id).to_i + 100_000 + + results = [ + [{ filename: "u/#{missing_id}/" }, { filename: "u/#{task_a.id}/" }], # one side deleted + [{ filename: "u/#{task_a.id}/" }, { filename: "u/#{task_b.id}/" }] # both present + ] + + linked = [] + run_moss_stats(unit, results) do + unit.stub(:create_moss_plagiarism_link, ->(t1, t2, _m, _w) { linked << [t1.id, t2.id] }) do + unit.update_moss_plagiarism_stats + end + end + + assert_equal [[task_a.id, task_b.id]], linked, 'the valid pair is linked, the deleted-task pair is skipped' + assert_not td.reload.plagiarism_updated, 'the flag is cleared after a clean pass' + end + + # If the results cannot be read at all the definition must stay flagged so the + # next scan retries it. It used to be un-flagged at the top of the loop, before + # the results were touched, so a mid-run failure lost the definition silently. + def test_moss_import_keeps_the_flag_when_results_cannot_be_read + unit = FactoryBot.create(:unit, with_students: false, stream_count: 0) + td = unit.task_definitions.first + td.update!(plagiarism_updated: true) + + boom = Object.new + boom.define_singleton_method(:extract_results) { |*_args| raise 'moss unavailable' } + + credentials = Object.new + credentials.define_singleton_method(:secret_key_moss) { 'test-moss-key' } + + Doubtfire::Application.stub(:credentials, credentials) do + MossRuby.stub(:new, boom) do + assert_raises(RuntimeError) { unit.update_moss_plagiarism_stats } + end + end + + assert td.reload.plagiarism_updated, 'the flag stays set so the scan is retried' + end + + # A match that fails to link must be logged and skipped so the remaining matches + # still process, but the definition must stay flagged so it is retried rather than + # silently marked done. + def test_moss_import_retries_the_definition_when_a_match_fails + unit = FactoryBot.create(:unit, with_students: false, stream_count: 0) + td = unit.task_definitions.first + td.update!(plagiarism_updated: true, plagiarism_warn_pct: 10) + + task_a = FactoryBot.create(:project, unit: unit).task_for_task_definition(td) + task_b = FactoryBot.create(:project, unit: unit).task_for_task_definition(td) + + results = [ + [{ filename: "u/#{task_a.id}/" }, { filename: "u/#{task_b.id}/" }], + [{ filename: "u/#{task_b.id}/" }, { filename: "u/#{task_a.id}/" }] + ] + + attempts = 0 + linker = lambda do |_t1, _t2, _match, _warn| + attempts += 1 + raise 'link failed' if attempts == 1 + end + + run_moss_stats(unit, results) do + unit.stub(:create_moss_plagiarism_link, linker) do + unit.update_moss_plagiarism_stats # must not raise + end + end + + assert_equal 2, attempts, 'the second match is still attempted after the first fails' + assert td.reload.plagiarism_updated, 'the flag stays set so the failed definition is retried' + end + + # The JPlag report maps zip entries back to tasks. A comparison that points at a + # task destroyed since the scan used to raise RecordNotFound and abort the whole + # report; find_by returns nil so the guard skips it and the rest still link. + def test_jplag_report_skips_a_missing_task_and_keeps_processing + unit = FactoryBot.create(:unit, with_students: false, stream_count: 0) + td = unit.task_definitions.first + + task_a = FactoryBot.create(:project, unit: unit).task_for_task_definition(td) + task_b = FactoryBot.create(:project, unit: unit).task_for_task_definition(td) + missing_id = Task.maximum(:id).to_i + 100_000 + + zip_path = build_jplag_report( + comparisons: [ + { first: 'subC', second: 'subD', max: 0.8 }, # subD was deleted - processed first + { first: 'subA', second: 'subB', max: 0.9 } # both present - must still be reached + ], + files: { 'subA' => task_a.id, 'subB' => task_b.id, 'subC' => task_a.id, 'subD' => missing_id } + ) + + linked = [] + unit.stub(:create_jplag_plagiarism_link, ->(t1, t2, _warn, _max) { linked << [t1.id, t2.id] }) do + assert_nothing_raised do + unit.send(:process_jplag_plagiarism_report, zip_path, 25, false) + end + end + + assert_equal [[task_a.id, task_b.id]], linked, 'the present pair links, the deleted-task pair is skipped' + ensure + File.delete(zip_path) if zip_path && File.exist?(zip_path) + end + + private + + # Builds a minimal JPlag report zip: a topComparisons.json plus one files/// + # entry per submission, which is how process_jplag_plagiarism_report maps a comparison + # back to its task ids. + def build_jplag_report(comparisons:, files:) + path = Rails.root.join('tmp', "jplag-report-#{SecureRandom.hex(4)}.zip").to_s + FileUtils.mkdir_p(File.dirname(path)) + top = comparisons.map do |c| + { 'firstSubmission' => c[:first], 'secondSubmission' => c[:second], 'similarities' => { 'MAX' => c[:max] } } + end + Zip::File.open(path, Zip::File::CREATE) do |zip| + zip.get_output_stream('topComparisons.json') { |f| f.write(top.to_json) } + files.each do |submission, task_id| + zip.get_output_stream("files/#{submission}/#{task_id}/src.java") { |f| f.write('// code') } + end + end + path + end + + # Runs the block with credentials and MossRuby stubbed so update_moss_plagiarism_stats + # reads the given results without a real MOSS key or network call. + def run_moss_stats(_unit, results) + fake_moss = Object.new + fake_moss.define_singleton_method(:extract_results) { |*_args| results } + + credentials = Object.new + credentials.define_singleton_method(:secret_key_moss) { 'test-moss-key' } + + Doubtfire::Application.stub(:credentials, credentials) do + MossRuby.stub(:new, fake_moss) do + yield + end + end + end end diff --git a/test/models/task_test.rb b/test/models/task_test.rb index 3597d8a86d..e432bd9ed1 100644 --- a/test/models/task_test.rb +++ b/test/models/task_test.rb @@ -344,18 +344,21 @@ def test_image_upload project = unit.active_projects.first + task = project.task_for_task_definition(td) + clear_submission(task) + add_auth_header_for user: unit.main_convenor_user post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status + assert_equal 201, last_response.status, last_response_body - task = project.task_for_task_definition(td) task.move_files_to_in_process(FileHelper.student_work_dir(:new)) assert File.exist? "#{Doubtfire::Application.config.student_work_dir}/in_process/#{task.id}/000-image.jpg" - - td.destroy + ensure + clear_submission(task) if task + td&.destroy end def test_pdf_creation_with_jpg @@ -647,155 +650,7 @@ def test_ipynb_to_pdf unit.destroy! end - def test_code_submission_with_long_lines - unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) - td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task with super ling lines in code submission', - description: 'Code task', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'Long', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'long.py', "type" => 'code' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) - td.save! - - data_to_post = { - trigger: 'ready_for_feedback' - } - - data_to_post = with_file('test_files/submissions/long.py', 'application/json', data_to_post) - - project = unit.active_projects.first - - add_auth_header_for user: unit.main_convenor_user - - post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post - - assert_equal 201, last_response.status, last_response_body - - # test submission generation - task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf(log_to_stdout: true) - path = task.zip_file_path_for_done_task - assert path - assert File.exist? path - assert File.exist? task.final_pdf_path - - # ensure the notice is included when rendered files are truncated - reader = PDF::Reader.new(task.final_pdf_path) - assert reader.pages[1].text.include? "This file has additional line breaks applied" - - # submit a normal file and ensure the notice is not included in the PDF - data_to_post = { - trigger: 'ready_for_feedback' - } - - data_to_post = with_file('test_files/submissions/normal.py', 'application/json', data_to_post) - project = unit.active_projects.first - add_auth_header_for user: unit.main_convenor_user - post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body - - # test submission generation - task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf(log_to_stdout: true) - path = task.zip_file_path_for_done_task - assert path - assert File.exist? path - assert File.exist? task.final_pdf_path - - # ensure the notice is not included - reader = PDF::Reader.new(task.final_pdf_path) - assert_not reader.pages[1].text.include? "This file has additional line breaks applied" - - td.destroy - assert_not File.exist? path - unit.destroy! - end - - def test_code_submission_with_long_lines - unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) - td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task with super ling lines in code submission', - description: 'Code task', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'Long', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'long.py', "type" => 'code' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) - td.save! - - data_to_post = { - trigger: 'ready_for_feedback' - } - - data_to_post = with_file('test_files/submissions/long.py', 'application/json', data_to_post) - - project = unit.active_projects.first - - add_auth_header_for user: unit.main_convenor_user - - post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post - - assert_equal 201, last_response.status, last_response_body - - # test submission generation - task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf(log_to_stdout: true) - path = task.zip_file_path_for_done_task - assert path - assert File.exist? path - assert File.exist? task.final_pdf_path - - # ensure the notice is included when rendered files are truncated - reader = PDF::Reader.new(task.final_pdf_path) - assert reader.pages[1].text.include? "This file has additional line breaks applied" - - # submit a normal file and ensure the notice is not included in the PDF - data_to_post = { - trigger: 'ready_for_feedback' - } - - data_to_post = with_file('test_files/submissions/normal.py', 'application/json', data_to_post) - project = unit.active_projects.first - add_auth_header_for user: unit.main_convenor_user - post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body - - # test submission generation - task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf(log_to_stdout: true) - path = task.zip_file_path_for_done_task - assert path - assert File.exist? path - assert File.exist? task.final_pdf_path - - # ensure the notice is not included - reader = PDF::Reader.new(task.final_pdf_path) - assert_not reader.pages[1].text.include? "This file has additional line breaks applied" - - td.destroy - assert_not File.exist? path - unit.destroy! - end - - def test_code_submission_with_long_lines + def test_code_submission_pdf_adds_line_break_notice_only_for_long_lines unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) td = TaskDefinition.new({ unit_id: unit.id, @@ -976,7 +831,10 @@ def test_pdf_creation_fails_on_invalid_pdf rescue StandardError => e task.reload - assert_equal 2, task.comments.count + # The status comment for the move to fix, the automatic resubmission + # extension that comes with it, and the automated comment about the failure + assert_equal 3, task.comments.count + assert_equal 1, task.comments.where(type: 'ExtensionComment').count assert task.comments.last.comment.starts_with?('**Automated Comment**:') assert task.comments.last.comment.include?(e.message.to_s) @@ -1764,4 +1622,438 @@ def test_prerequisite_tasks_change_to_fix_and_resubmit assert_equal TaskStatus.complete, task3.task_status, "Task not Ready for Feedback should not be affected" assert_equal TaskStatus.ready_for_feedback, task4.task_status # Task 4 has no prerequsite links end + + # + # Build a unit with a single task, for the automatic resubmission extension + # tests below. An overdue target date is the case that mattered, because a + # task that is already late stays inside the one week window after it has + # been extended, so every repeat of the assessment used to add another week. + # + def create_task_for_resubmission_extension(weeks_on_resubmit: 1, target_date: Time.zone.now + 2.days) + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0, start_date: Time.zone.now - 6.weeks, end_date: Time.zone.now + 10.weeks) + unit.allow_student_extension_requests = true + unit.extension_weeks_on_resubmit_request = weeks_on_resubmit + unit.save! + + td = TaskDefinition.new({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Resubmission task', + description: 'Resubmission task', + weighting: 4, + target_grade: 0, + start_date: unit.start_date, + target_date: target_date, + abbreviation: 'RESUB', + restrict_status_updates: false, + upload_requirements: [ ], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + td.save! + + project = unit.active_projects.first + [unit, td, project.task_for_task_definition(td)] + end + + # Assessing the same submission again must not move the deadline again. + def test_resubmission_extension_granted_once_per_round + unit, _td, task = create_task_for_resubmission_extension(target_date: Time.zone.now - 3.weeks) + tutor = unit.main_convenor_user + + task.assess(TaskStatus.fix_and_resubmit, tutor) + assert_equal 1, task.reload.extensions, 'The first fix should grant the resubmission extension' + + first_due_date = task.due_date + + task.assess(TaskStatus.fix_and_resubmit, tutor) + assert_equal 1, task.reload.extensions, 'Assessing the same submission again must not extend again' + assert_equal first_due_date, task.due_date, 'The effective deadline must not move on a repeated assessment' + + task.assess(TaskStatus.discuss, tutor) + assert_equal 1, task.reload.extensions, 'Another resubmission status in the same round must not extend again' + assert_equal first_due_date, task.due_date + + unit.destroy! + end + + # A new submission starts a new round of feedback, which earns its own + # extension. That is the rule the unit has today and it is unchanged. + def test_resubmission_extension_returns_after_a_new_submission + unit, _td, task = create_task_for_resubmission_extension + tutor = unit.main_convenor_user + student = unit.active_projects.first.student + + task.assess(TaskStatus.fix_and_resubmit, tutor) + assert_equal 1, task.reload.extensions + + # A week later the student resubmits and is sent back to fix it again + travel_to Time.zone.now + 8.days do + task.submit(student) + task.assess(TaskStatus.fix_and_resubmit, tutor) + assert_equal 2, task.reload.extensions, 'A new submission earns a new resubmission extension' + end + + unit.destroy! + end + + # The extension has to say why it happened and what triggered it. + def test_resubmission_extension_records_its_reason + unit, _td, task = create_task_for_resubmission_extension + tutor = unit.main_convenor_user + + task.assess(TaskStatus.fix_and_resubmit, tutor) + task.reload + + extension = task.resubmission_extension_comment + assert_not_nil extension, 'The automatic extension should be recorded against the task' + assert_equal 'ExtensionComment', extension.type + assert_equal 1, extension.extension_weeks + assert extension.extension_granted, 'The recorded extension should be marked as granted' + assert_equal TaskStatus.fix_and_resubmit, extension.task_status, 'The status that triggered the extension should be recorded' + assert_equal tutor, extension.assessor + assert extension.assessed? + assert extension.comment.present?, 'The extension should explain itself to the student' + assert extension.extension_response.include?(task.due_date.strftime('%a %b %e')), 'The response should name the new deadline' + + serialized = extension.serialize(tutor) + assert serialized[:resubmission_extension], 'The interface needs to know OnTrack worked this extension out itself' + assert_equal :fix_and_resubmit, serialized[:source_status] + + unit.destroy! + end + + # A larger extension granted later must survive a repeated assessment. + def test_resubmission_extension_does_not_shorten_a_later_extension + unit, _td, task = create_task_for_resubmission_extension(target_date: Time.zone.now - 3.weeks) + tutor = unit.main_convenor_user + + task.assess(TaskStatus.fix_and_resubmit, tutor) + assert_equal 1, task.reload.extensions + + assert task.grant_extension(tutor, 2), 'A tutor should be able to grant a further extension' + assert_equal 3, task.reload.extensions + later_due_date = task.due_date + + task.assess(TaskStatus.fix_and_resubmit, tutor) + task.reload + assert_equal 3, task.extensions, 'A later extension must not be lost, and must not be added to' + assert_equal later_due_date, task.due_date + + unit.destroy! + end + + # The recursive fix of dependent tasks runs an assessment on each of them. + # Replaying it must not extend those tasks a second time. + def test_recursive_fix_does_not_extend_dependent_tasks_twice + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + unit.extension_weeks_on_resubmit_request = 1 + unit.save! + + tutor = FactoryBot.create(:user, :tutor) + unit.employ_staff(tutor, Role.tutor) + + td1 = unit.task_definitions.first + td2 = unit.task_definitions.second + + [td1, td2].each do |td| + td.update!(start_date: Time.zone.now - 6.weeks, target_date: Time.zone.now - 3.weeks, due_date: Time.zone.now + 8.weeks, target_grade: 0) + end + + TaskPrerequisite.create!( + task_definition: td2, + prerequisite: td1, + task_status_id: TaskStatus.ready_for_feedback.id + ) + + project = unit.active_projects.first + task1 = project.task_for_task_definition(td1) + task2 = project.task_for_task_definition(td2) + + task1.update!(task_status: TaskStatus.ready_for_feedback) + task2.update!(task_status: TaskStatus.ready_for_feedback) + + task1.assess(TaskStatus.fix_and_resubmit, tutor, Time.zone.now, true) + assert_equal 1, task1.reload.extensions, 'The assessed task should be extended once' + assert_equal 1, task2.reload.extensions, 'The dependent task should be extended once' + + # Replay the same event. The dependent task is put back to ready for + # feedback so the recursion reaches it again, as a duplicate event would. + task2.update!(task_status: TaskStatus.ready_for_feedback) + task1.assess(TaskStatus.fix_and_resubmit, tutor, Time.zone.now, true) + + assert_equal 1, task1.reload.extensions, 'The assessed task must not be extended twice' + assert_equal 1, task2.reload.extensions, 'The dependent task must not be extended twice' + + unit.destroy! + end + + # The window is measured from the assessment being processed, not from the + # wall clock, so replaying an old event gives the answer it gave then. + def test_resubmission_extension_window_uses_the_assessment_time + unit, _td, task = create_task_for_resubmission_extension + tutor = unit.main_convenor_user + + assert task.resubmission_extension_window_open?(Time.zone.now), 'The deadline is two days away, so the window is open now' + assert_not task.resubmission_extension_window_open?(Time.zone.now - 3.weeks), 'Three weeks ago the deadline was not close' + + task.assess(TaskStatus.fix_and_resubmit, tutor, Time.zone.now - 3.weeks) + assert_equal 0, task.reload.extensions, 'An assessment made when the deadline was far away should not extend it' + + unit.destroy! + end + + # + # Melbourne puts its clocks back at 03:00 on Sunday 5 April 2026 and forward + # at 02:00 on Sunday 4 October 2026, so 2 April and 8 October are +11:00 while + # 9 April and 1 October are +10:00. Those are the four dates the tests below + # use. + # + # Every one of them leaves the application zone alone on purpose. Nothing in + # config/ sets config.time_zone, so that zone is UTC, and the whole point of + # the fix is that the deadline maths no longer depends on it. The zone comes + # off the campus the student is enrolled at. + # + + # The last moment of a given day, anywhere on earth. Fixed offset, so it never + # observes daylight saving itself. + def end_of_day_anywhere_on_earth(year, month, day) + Time.new(year, month, day, 23, 59, 59, '-12:00') + end + + # Put the campuses these tasks belong to onto a real Australian zone, then put + # them back so nothing else in the suite sees the change. + def with_campus_timezone(zone_name, *tasks) + campuses = tasks.map { |task| task.project.campus }.compact.uniq + previous = campuses.map { |campus| [campus, campus.read_attribute(:timezone)] } + + campuses.each { |campus| campus.update!(timezone: zone_name) } + yield + ensure + previous.each { |campus, was| campus.update!(timezone: was) } + end + + # A deadline set at the same time of day on either side of a daylight saving + # change has to land on the day it was set for, and two of them a week apart + # have to stay a week apart. + # + # This used to read the day, month and year straight off the deadline as it + # was loaded, which meant reading them in UTC. 10:30 in Melbourne is the + # previous day in UTC through summer and the same day through winter, so the + # effective deadline jumped a whole day at the boundary. + def test_effective_deadline_does_not_drift_across_a_daylight_saving_boundary + melbourne = ActiveSupport::TimeZone['Australia/Melbourne'] + unit, td, task = create_task_for_resubmission_extension + + with_campus_timezone('Australia/Melbourne', task) do + # The week the clocks go back, then the week they go forward + [[[2026, 4, 2], [2026, 4, 9]], [[2026, 10, 1], [2026, 10, 8]]].each do |first, second| + deadlines = [first, second].map do |year, month, day| + td.update!(target_date: melbourne.local(year, month, day, 10, 30, 0)) + task.reload.effective_deadline + end + + assert_equal end_of_day_anywhere_on_earth(*first), deadlines.first, + "A task due at 10:30 in Melbourne on #{first.join('-')} runs to the end of that day, not the one before" + assert_equal end_of_day_anywhere_on_earth(*second), deadlines.second, + "A task due at 10:30 in Melbourne on #{second.join('-')} runs to the end of that day, not the one before" + assert_equal 7.days.to_i, (deadlines.second - deadlines.first).to_i, + 'Two deadlines a week apart on the campus calendar stay a week apart when the clocks change between them' + end + end + + unit.destroy! + end + + # Seven days has to mean seven days on the campus calendar. The week Melbourne + # moves onto daylight saving is 167 real hours long and the week it moves off + # is 169, so counting a flat 168 moves the edge of the window by an hour. + # + # The assessment time is handed in the way Task#assess gets it. Nothing sets + # config.time_zone, so that is a UTC value, and the whole point is that the + # window is then measured on the campus clock rather than on that one. Feed + # this a Melbourne time instead and it passes either way, because adding a + # duration to a value that is already in the campus zone does the right thing + # on its own and the test proves nothing. + def test_resubmission_extension_window_keeps_its_wall_clock_across_a_daylight_saving_boundary + melbourne = ActiveSupport::TimeZone['Australia/Melbourne'] + unit, _td, task = create_task_for_resubmission_extension + + assert_equal 'UTC', Time.zone.name, 'This test is only meaningful while the application zone is not the campus zone' + + with_campus_timezone('Australia/Melbourne', task) do + # Nine in the morning in Melbourne on the Thursday before the clocks go + # forward, arriving as the UTC instant the application would hand over + forward_from = melbourne.local(2026, 10, 1, 9, 0, 0).in_time_zone(Time.zone) + forward_to = task.resubmission_extension_window_end(forward_from) + + assert_equal 'UTC', forward_from.time_zone.name + assert_equal melbourne.local(2026, 10, 8, 9, 0, 0), forward_to, + 'Seven days after nine in the morning is nine in the morning, in the week the clocks go forward' + assert_equal 167, ((forward_to - forward_from) / 3600.0).round, + 'That week is 167 real hours, so a flat 168 would push the edge of the window an hour late' + + back_from = melbourne.local(2026, 4, 2, 9, 0, 0).in_time_zone(Time.zone) + back_to = task.resubmission_extension_window_end(back_from) + + assert_equal 'UTC', back_from.time_zone.name + assert_equal melbourne.local(2026, 4, 9, 9, 0, 0), back_to, + 'Seven days after nine in the morning is nine in the morning, in the week the clocks go back' + assert_equal 169, ((back_to - back_from) / 3600.0).round, + 'That week is 169 real hours, so a flat 168 would pull the edge of the window an hour early' + end + + unit.destroy! + end + + # The whole thing end to end, over the weekend the clocks actually change. A + # task due Monday 5 October 2026, sent back on Thursday 1 October, has to come + # out due Monday 12 October. Not Sunday the 11th, and not an hour either side + # of the end of the 12th. + def test_resubmission_extension_lands_on_the_right_day_across_a_daylight_saving_boundary + melbourne = ActiveSupport::TimeZone['Australia/Melbourne'] + unit, td, task = create_task_for_resubmission_extension + tutor = unit.main_convenor_user + + with_campus_timezone('Australia/Melbourne', task) do + td.update!(target_date: melbourne.local(2026, 10, 5, 10, 30, 0)) + task.reload + + assert_equal end_of_day_anywhere_on_earth(2026, 10, 5), task.effective_deadline + + # Melbourne moves onto daylight saving on the Sunday in between + task.assess(TaskStatus.fix_and_resubmit, tutor, melbourne.local(2026, 10, 1, 9, 0, 0)) + task.reload + + assert_equal 1, task.extensions, 'A task due in four days should get the one week the unit grants' + assert_equal end_of_day_anywhere_on_earth(2026, 10, 12), task.effective_deadline, + 'One week after Monday the 5th is Monday the 12th, and the clock change must not make it the 11th' + assert_equal Date.new(2026, 10, 12), task.due_date.to_date + + # And the guard still holds on the far side of the change + task.assess(TaskStatus.fix_and_resubmit, tutor, melbourne.local(2026, 10, 6, 9, 0, 0)) + task.reload + + assert_equal 1, task.extensions, 'Reassessing the same submission after the clocks change must not extend it again' + assert_equal end_of_day_anywhere_on_earth(2026, 10, 12), task.effective_deadline + end + + unit.destroy! + end + + # "Automatic" already meant something else on ExtensionComment - a request a + # student made that the unit approved without a person weighing it up. The + # extension OnTrack works out for itself is a different thing and answers to a + # different name, so a reader cannot take one for the other. + def test_a_student_request_is_not_reported_as_a_resubmission_extension + unit, _td, task = create_task_for_resubmission_extension(target_date: Time.zone.now - 3.weeks) + convenor = unit.main_convenor_user + student = unit.active_projects.first.student + + requested = task.apply_for_extension(student, 'I have been unwell all week', 1) + task.reload + + assert requested.assessed?, 'The unit approves requests inside the deadline without asking anyone' + assert requested.extension_granted + assert_not requested.resubmission_extension?, 'A student asking for time is not something OnTrack worked out itself' + assert_not requested.serialize(convenor)[:resubmission_extension] + assert_nil requested.serialize(convenor)[:source_status] + + task.assess(TaskStatus.fix_and_resubmit, convenor) + task.reload + worked_out = task.resubmission_extension_comment + + assert_not_nil worked_out, 'Sending the task back near the deadline should still earn its own extension' + assert worked_out.resubmission_extension?, 'That one carries the status that triggered it' + assert_equal :fix_and_resubmit, worked_out.serialize(convenor)[:source_status] + assert_equal 2, task.extensions, 'The two are counted separately' + + unit.destroy! + end + + # The extension only applies when the deadline is close. + def test_no_resubmission_extension_when_the_deadline_is_far_away + unit, _td, task = create_task_for_resubmission_extension(target_date: Time.zone.now + 4.weeks) + tutor = unit.main_convenor_user + + task.assess(TaskStatus.fix_and_resubmit, tutor) + assert_equal 0, task.reload.extensions, 'A task due in four weeks should not be extended' + assert_nil task.resubmission_extension_comment + + unit.destroy! + end + + # Units that turn the automatic extension off must not get one. + def test_no_resubmission_extension_when_the_unit_grants_zero_weeks + unit, _td, task = create_task_for_resubmission_extension(weeks_on_resubmit: 0) + tutor = unit.main_convenor_user + + task.assess(TaskStatus.fix_and_resubmit, tutor) + assert_equal 0, task.reload.extensions + assert_nil task.resubmission_extension_comment + + unit.destroy! + end + + # A failed archive write must not destroy the previously accepted submission. + # compress_new_to_done used to delete the done zip before rebuilding it, so a + # raise part way through the build left the task with no readable submission at + # all. The stub below stands in for any failure while writing the new archive. + def test_compress_new_to_done_keeps_the_previous_zip_when_the_write_fails + unit = Unit.first + td = TaskDefinition.new( + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Atomic done zip', + description: 'atomic done zip', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'TaskAtomicDoneZip', + restrict_status_updates: false, + upload_requirements: [{ 'key' => 'file0', 'name' => 'A Document', 'type' => 'document' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + ) + td.save! + + task = unit.active_projects.first.task_for_task_definition(td) + done_zip = task.zip_file_path_for_done_task + + place_one_document = lambda do + new_dir = task.student_work_dir(:new, true) + FileUtils.cp(test_file_path('submissions/1.2P.pdf'), "#{new_dir}000-document.pdf") + end + + # First, a real submission so there is a previously accepted zip on disk. + place_one_document.call + assert task.compress_new_to_done, 'the first compress should succeed' + assert File.exist?(done_zip), 'the done zip should exist after a successful compress' + original_bytes = File.binread(done_zip) + assert(Zip::File.open(done_zip) { |z| z.entries.any? }, 'the done zip should be a readable archive') + assert_empty Dir.glob("#{done_zip}.tmp-*"), 'a successful compress must not leave a temporary archive' + + # Now a second submission whose archive write fails part way through. The stub + # creates the temporary archive first, then raises, so it also exercises the + # cleanup of the half-written temp file. + place_one_document.call + partial_write = lambda do |path, *_rest| + File.binwrite(path, 'partial archive bytes') + raise 'simulated failure while writing the new archive' + end + Zip::File.stub(:open, partial_write) do + assert_raises(RuntimeError) { task.compress_new_to_done } + end + + # The previously accepted submission must be untouched, not deleted or corrupted. + assert File.exist?(done_zip), 'the previous done zip must survive a failed write' + assert_equal original_bytes, File.binread(done_zip), 'the previous done zip must be byte-for-byte unchanged' + assert(Zip::File.open(done_zip) { |z| z.entries.any? }, 'the previous done zip must still be readable') + assert_empty Dir.glob("#{done_zip}.tmp-*"), 'the failed write must not leak a temporary archive' + + td.destroy + end end diff --git a/test/models/unit_calendar_test.rb b/test/models/unit_calendar_test.rb new file mode 100644 index 0000000000..d89f3c85c8 --- /dev/null +++ b/test/models/unit_calendar_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UnitCalendarTest < ActiveSupport::TestCase + def test_date_for_week_and_day_keeps_earlier_weekday_in_the_requested_week + unit = Unit.new(start_date: Time.zone.local(2026, 8, 7)) # Friday + + assert_equal Time.zone.local(2026, 8, 9), unit.date_for_week_and_day(1, 'Sun') + end +end diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index b53659ea02..0da1d2eaa7 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -200,7 +200,13 @@ def test_rollover_of_learning_summary @unit.draft_task_definition = lsr @unit.save - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil + unit2 = nil + assert_difference( + -> { NewTaskAvailableNotificationJob.jobs.size }, + @unit.task_definitions.count + ) do + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil + end assert_not_nil unit2.draft_task_definition refute_equal lsr, unit2.draft_task_definition diff --git a/test/models/unit_similarity_cleanup_test.rb b/test/models/unit_similarity_cleanup_test.rb new file mode 100644 index 0000000000..ccc695a29d --- /dev/null +++ b/test/models/unit_similarity_cleanup_test.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UnitSimilarityCleanupTest < ActiveSupport::TestCase + def test_jplag_cleanup_preserves_another_units_workspace + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + current_root = Rails.root.join('tmp', 'jplag', "unit-#{unit.id}") + tasks_dir = current_root.join('task-definition') + other_root = Rails.root.join('tmp', 'jplag', "other-unit-#{SecureRandom.hex(6)}") + sentinel = other_root.join('in-progress.sentinel') + + FileUtils.mkdir_p(tasks_dir) + FileUtils.mkdir_p(other_root) + FileUtils.touch(sentinel) + + unit.stub(:system, true) do + unit.send( + :run_jplag_on_done_files, + jplag_task_definition, + tasks_dir, + [], + Rails.root.join('tmp/jplag-results/report.jplag').to_s + ) + end + + assert_not Dir.exist?(tasks_dir), 'the completed task workspace should be removed' + assert File.exist?(sentinel), "another unit's in-progress workspace must survive cleanup" + ensure + FileUtils.rm_rf(current_root) if current_root + FileUtils.rm_rf(other_root) if other_root + end + + def test_jplag_cleanup_runs_when_the_container_command_fails + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + current_root = Rails.root.join('tmp', 'jplag', "unit-#{unit.id}") + tasks_dir = current_root.join('task-definition') + other_root = Rails.root.join('tmp', 'jplag', "other-unit-#{SecureRandom.hex(6)}") + sentinel = other_root.join('in-progress.sentinel') + + FileUtils.mkdir_p(tasks_dir) + FileUtils.mkdir_p(other_root) + FileUtils.touch(sentinel) + + system_calls = 0 + run_command = lambda do |*_command| + system_calls += 1 + system_calls < 3 + end + + error = assert_raises(RuntimeError) do + unit.stub(:system, run_command) do + unit.send( + :run_jplag_on_done_files, + jplag_task_definition, + tasks_dir, + [], + Rails.root.join('tmp/jplag-results/report.jplag').to_s + ) + end + end + + assert_equal 'Failed to run JPlag similarity check', error.message + assert_not Dir.exist?(tasks_dir), 'the failed task workspace should be removed' + assert File.exist?(sentinel), "another unit's in-progress workspace must survive failed cleanup" + ensure + FileUtils.rm_rf(current_root) if current_root + FileUtils.rm_rf(other_root) if other_root + end + + def test_jplag_unit_root_cleanup_runs_after_a_top_level_failure + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + current_root = Rails.root.join('tmp', 'jplag', "unit-#{unit.id}") + other_root = Rails.root.join('tmp', 'jplag', "other-unit-#{SecureRandom.hex(6)}") + sentinel = other_root.join('in-progress.sentinel') + + FileUtils.mkdir_p(current_root) + FileUtils.mkdir_p(other_root) + FileUtils.touch(sentinel) + + failing_definitions = Object.new + failing_definitions.define_singleton_method(:each) { raise 'simulated scan setup failure' } + + error = assert_raises(RuntimeError) do + unit.stub(:task_definitions, failing_definitions) do + unit.check_jplag_similarity(force: true) + end + end + + assert_equal 'simulated scan setup failure', error.message + assert_not Dir.exist?(current_root), 'the failed unit workspace should be removed' + assert File.exist?(sentinel), "another unit's in-progress workspace must survive unit cleanup" + ensure + FileUtils.rm_rf(current_root) if current_root + FileUtils.rm_rf(other_root) if other_root + end + + def test_hostile_unit_code_cannot_escape_workspace_or_reach_a_shell + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + token = SecureRandom.hex(6) + shell_marker = Rails.root.join("jplag-shell-marker-#{token}") + hostile_code = "../escaped-#{token};touch #{shell_marker.basename};#" + unit.update!(code: hostile_code) + + task_definition = hostile_jplag_task_definition(unit.id + 9_000_000) + current_root = Rails.root.join('tmp', 'jplag', "unit-#{unit.id}") + expected_tasks_dir = current_root.join(task_definition.id.to_s) + legacy_escape_root = Rails.root.join('tmp', 'jplag', "#{hostile_code}-#{unit.id}") + other_root = Rails.root.join('tmp', 'jplag', "unit-#{unit.id + 8_000_000}") + sentinel = other_root.join('in-progress.sentinel') + extracted_to = [] + docker_calls = [] + tasks = hostile_tasks(extracted_to) + + FileUtils.mkdir_p(other_root) + FileUtils.touch(sentinel) + + capture_system = lambda do |*argv| + docker_calls << argv + true + end + + unit.stub(:task_definitions, [task_definition]) do + unit.stub(:tasks_for_definition, tasks) do + unit.stub(:process_jplag_plagiarism_report, true) do + unit.stub(:system, capture_system) do + unit.check_jplag_similarity(force: true) + end + end + end + end + + assert_equal [expected_tasks_dir, expected_tasks_dir], extracted_to + assert_equal 3, docker_calls.length + assert docker_calls.all? { |argv| argv.length > 1 }, 'derived paths must never be passed through a command string' + assert(docker_calls.all? { |argv| argv.first == 'docker' }) + assert_includes docker_calls.last, "/tmp/jplag/unit-#{unit.id}/#{task_definition.id}/submissions" + assert_not File.exist?(shell_marker), 'unit code shell metacharacters must never execute' + assert_not Dir.exist?(legacy_escape_root), 'unit code path traversal must not create a workspace' + assert_not Dir.exist?(current_root), 'the hostile-code unit workspace should be cleaned' + assert File.exist?(sentinel), "another unit's workspace must survive hostile-code cleanup" + ensure + FileUtils.rm_rf(current_root) if current_root + FileUtils.rm_rf(legacy_escape_root) if legacy_escape_root + FileUtils.rm_rf(other_root) if other_root + FileUtils.rm_f(shell_marker) if shell_marker + end + + private + + def jplag_task_definition + task_definition = Struct.new(:plagiarism_warn_pct, :upload_requirements, :similarity_language) + .new(50, [], 'java') + task_definition.define_singleton_method(:has_task_resources?) { false } + task_definition + end + + def hostile_jplag_task_definition(id) + task_definition = Struct.new( + :id, + :similarity_language, + :upload_requirements, + :updated_at, + :name, + :plagiarism_warn_pct, + :group_set, + :abbreviation + ).new( + id, + 'java', + [{ 'type' => 'code', 'tii_check' => true, 'name' => 'source' }], + Time.zone.now, + 'Hostile code task', + 50, + nil, + 'HOSTILE' + ) + task_definition.define_singleton_method(:has_task_resources?) { false } + task_definition.define_singleton_method(:glob_for_upload_requirement) { |_index| '*' } + task_definition + end + + def hostile_tasks(extracted_to) + task_list = Array.new(2) do + task = Object.new + task.define_singleton_method(:has_pdf) { true } + task.define_singleton_method(:extract_file_from_done) do |to_path, _pattern, _destination| + extracted_to << Pathname(to_path) + end + task + end + + tasks = Object.new + tasks.define_singleton_method(:select) { |&block| task_list.select(&block) } + tasks.define_singleton_method(:where) { |_query, _time| tasks } + tasks + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 4cb9d8ac8f..b94b4b268c 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -17,12 +17,15 @@ class UserTest < ActiveSupport::TestCase nickname: 'Test', role_id: 1, email: 'test@test.org', - username: 'metoo', - password: 'password', - password_confirmation: 'password' + username: 'metoo' } - User.create!(profile) - assert User.last, profile + user = User.new(profile) + user.password = 'password' + user.save! + + assert_equal profile.stringify_keys, user.attributes.slice(*profile.stringify_keys.keys) + assert user.authenticate?('password') + assert User.last.display_peer_progress? end def test_user_is_valid @@ -46,4 +49,57 @@ def test_can_create_multiple_auth_tokens t2 = user.generate_authentication_token! assert_not_equal t1, t2 end + + def test_valid_theme_preferences + [nil, 'light', 'dark', 'system'].each do |theme| + user = FactoryBot.build(:user, theme_preference: theme) + assert user.valid?, "expected #{theme.inspect} to be a valid theme_preference" + end + end + + def test_invalid_theme_preference + user = FactoryBot.build(:user, theme_preference: 'sepia') + refute user.valid? + end + + def test_theme_preference_timestamp_tracks_actual_preference_changes + user = FactoryBot.create(:user) + + assert_nil user.theme_preference + assert_nil user.theme_preference_updated_at + + first_choice_at = Time.zone.parse('2026-08-30 10:00:00 UTC') + travel_to first_choice_at do + user.update!(theme_preference: 'dark') + end + assert_equal first_choice_at, user.theme_preference_updated_at + + travel_to first_choice_at + 30.minutes do + user.update!(theme_preference: 'dark') + end + assert_equal first_choice_at, user.theme_preference_updated_at, + 'model writes of the same value are not API synchronization writes' + + travel_to first_choice_at + 1.hour do + user.update!(nickname: 'Still dark') + end + assert_equal first_choice_at, user.theme_preference_updated_at, + 'unrelated updates must not make the preference look newer' + + second_choice_at = first_choice_at + 2.hours + travel_to second_choice_at do + user.update!(theme_preference: 'light') + end + assert_equal second_choice_at, user.theme_preference_updated_at + end + + def test_clearing_theme_preference_restores_the_never_chosen_state + user = FactoryBot.create(:user, theme_preference: 'dark') + assert_not_nil user.theme_preference_updated_at + + user.update!(theme_preference: nil) + + assert_nil user.theme_preference + assert_nil user.theme_preference_updated_at + 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/notification_service_test.rb b/test/services/notification_service_test.rb new file mode 100644 index 0000000000..8e1383a5c4 --- /dev/null +++ b/test/services/notification_service_test.rb @@ -0,0 +1,350 @@ +require 'test_helper' +require 'minitest/mock' + +class NotificationServiceTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + PushNotificationDeliveryJob.clear + end + + def test_notify_creates_a_notification_and_queues_id_only_channel_jobs + user = FactoryBot.create(:user) + notification = nil + + assert_difference(-> { NotificationEmailJob.jobs.size }, 1) do + assert_difference(-> { PushNotificationDeliveryJob.jobs.size }, 1) do + notification = NotificationService.notify( + user: user, + type: 'task', + event: 'task_comment_created', + message: 'Your tutor commented on your task.', + link: "/projects/#{user.id}" + ) + end + end + + assert notification.persisted? + assert_equal 'task', notification.notification_type + assert_equal 'task_comment_created', notification.event + + job = NotificationEmailJob.jobs.last + assert_equal 'NotificationEmailJob', job['class'] + assert_equal 'mailers', job['queue'] + assert_equal [notification.id], job['args'] + + push_job = PushNotificationDeliveryJob.jobs.last + assert_equal 'PushNotificationDeliveryJob', push_job['class'] + assert_equal 'notifications', push_job['queue'] + assert_equal [notification.id], push_job['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_email_is_not_queued_until_the_creating_transaction_commits + user = FactoryBot.create(:user) + notification = nil + + ActiveRecord::Base.transaction do + notification = NotificationService.notify( + user: user, type: 'general', event: 'group_membership_changed', message: 'In a transaction.' + ) + + assert notification.persisted? + # A worker picking the job up here could not see the row yet, so nothing + # may be queued before the transaction commits. + assert_empty NotificationEmailJob.jobs + end + + assert_equal 1, NotificationEmailJob.jobs.size + assert_equal [notification.id], NotificationEmailJob.jobs.last['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_a_rolled_back_transaction_queues_no_email + user = FactoryBot.create(:user) + + ActiveRecord::Base.transaction do + NotificationService.notify( + user: user, type: 'general', event: 'rolled_back_event', message: 'Never happened.' + ) + raise ActiveRecord::Rollback + end + + assert_equal 0, Notification.where(event: 'rolled_back_event').count + assert_empty NotificationEmailJob.jobs + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_notify_requires_an_event_keyword + user = FactoryBot.create(:user) + + assert_raises ArgumentError do + NotificationService.notify(user: user, type: 'general', message: 'No event given.') + end + end + + def test_blank_event_is_rejected + user = FactoryBot.create(:user) + + assert_no_difference 'Notification.count' do + assert_raises ActiveRecord::RecordInvalid do + NotificationService.notify(user: user, type: 'general', event: '', message: 'Blank event.') + end + end + + assert_empty NotificationEmailJob.jobs + assert_empty PushNotificationDeliveryJob.jobs + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_a_symbol_event_is_stored_as_a_string + user = FactoryBot.create(:user) + + notification = NotificationService.notify( + user: user, type: 'general', event: :task_comment_created, message: 'Symbol event.' + ) + + assert_equal 'task_comment_created', notification.event + end + + def test_message_at_the_validated_maximum_survives_a_round_trip + user = FactoryBot.create(:user) + long_message = 'a' * 500 + + notification = NotificationService.notify( + user: user, type: 'general', event: 'long_message_check', message: long_message + ) + + # Fails before the message column became text: 500 characters passed + # validation and were then truncated or rejected by VARCHAR(255). + assert_equal 500, notification.reload.message.length + end + + def test_notification_is_suppressed_when_the_category_preference_is_off + user = FactoryBot.create(:user, receive_feedback_notifications: false) + assert_no_difference 'Notification.count' do + result = NotificationService.notify( + user: user, type: 'feedback', event: 'task_comment_created', message: 'Suppressed.' + ) + assert_nil result + end + + assert_empty NotificationEmailJob.jobs + assert_empty PushNotificationDeliveryJob.jobs + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_feedback_notification_is_queued_when_the_category_preference_is_on + user = FactoryBot.create(:user, receive_feedback_notifications: true) + notification = nil + + assert_difference(-> { NotificationEmailJob.jobs.size }, 1) do + assert_difference(-> { PushNotificationDeliveryJob.jobs.size }, 1) do + assert_difference 'Notification.count', 1 do + notification = NotificationService.notify( + user: user, type: 'feedback', event: 'feedback_available', message: 'Feedback available.' + ) + + assert notification.persisted? + end + end + end + + assert_equal [notification.id], NotificationEmailJob.jobs.last['args'] + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_task_preference_gates_notifications_in_both_directions + user = FactoryBot.create(:user, receive_task_notifications: true) + notification = nil + + assert_difference(-> { NotificationEmailJob.jobs.size }, 1) do + assert_difference(-> { PushNotificationDeliveryJob.jobs.size }, 1) do + assert_difference 'Notification.count', 1 do + notification = NotificationService.notify( + user: user, type: 'task', event: 'task_due_date_changed', message: 'Task date changed.' + ) + + assert notification.persisted? + end + end + end + assert_equal [notification.id], NotificationEmailJob.jobs.last['args'] + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + + user.update!(receive_task_notifications: false) + + assert_no_difference -> { NotificationEmailJob.jobs.size } do + assert_no_difference -> { PushNotificationDeliveryJob.jobs.size } do + assert_no_difference 'Notification.count' do + result = NotificationService.notify( + user: user, type: 'task', event: 'task_due_date_changed', message: 'Suppressed task change.' + ) + + assert_nil result + end + end + end + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_portfolio_preference_gates_notifications_in_both_directions + user = FactoryBot.create(:user, receive_portfolio_notifications: true) + notification = nil + + assert_difference(-> { NotificationEmailJob.jobs.size }, 1) do + assert_difference(-> { PushNotificationDeliveryJob.jobs.size }, 1) do + assert_difference 'Notification.count', 1 do + notification = NotificationService.notify( + user: user, type: 'portfolio', event: 'portfolio_received', message: 'Portfolio received.' + ) + + assert notification.persisted? + end + end + end + assert_equal [notification.id], NotificationEmailJob.jobs.last['args'] + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + + user.update!(receive_portfolio_notifications: false) + + assert_no_difference -> { NotificationEmailJob.jobs.size } do + assert_no_difference -> { PushNotificationDeliveryJob.jobs.size } do + assert_no_difference 'Notification.count' do + result = NotificationService.notify( + user: user, type: 'portfolio', event: 'portfolio_received', message: 'Suppressed portfolio receipt.' + ) + + assert_nil result + end + end + end + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_types_without_a_preference_are_always_queued + user = FactoryBot.create( + :user, + receive_task_notifications: false, + receive_feedback_notifications: false, + receive_portfolio_notifications: false + ) + notification = nil + + assert_difference(-> { NotificationEmailJob.jobs.size }, 1) do + assert_difference(-> { PushNotificationDeliveryJob.jobs.size }, 1) do + notification = NotificationService.notify( + user: user, type: 'general', event: 'always_sent', message: 'General notice.' + ) + end + end + + assert notification.persisted? + assert_equal [notification.id], NotificationEmailJob.jobs.last['args'] + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end + + # 'extension' has no entry in Notification::PREFERENCE_FOR_TYPE, so + # deliver_to? returns true regardless of the three category toggles. The + # event name is the one ExtensionComment actually raises. + def test_extension_notifications_are_always_queued + user = FactoryBot.create( + :user, + receive_task_notifications: false, + receive_feedback_notifications: false, + receive_portfolio_notifications: false + ) + notification = nil + + assert_difference(-> { NotificationEmailJob.jobs.size }, 1) do + assert_difference(-> { PushNotificationDeliveryJob.jobs.size }, 1) do + notification = NotificationService.notify( + user: user, type: 'extension', event: 'extension_assessed', message: 'Extension decision available.' + ) + end + end + + assert notification.persisted? + assert_equal [notification.id], NotificationEmailJob.jobs.last['args'] + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_a_queue_failure_does_not_block_the_in_app_notification + user = FactoryBot.create(:user) + notification = nil + + NotificationEmailJob.stub(:perform_async, ->(_id) { raise 'redis unavailable' }) do + notification = NotificationService.notify( + user: user, type: 'general', event: 'queue_failure_check', message: 'Still saved.' + ) + + assert notification.persisted? + end + + assert_equal 0, NotificationEmailJob.jobs.size + assert_equal 1, PushNotificationDeliveryJob.jobs.size + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + assert_not_nil notification.reload.delivered_at + end + + def test_dedupe_key_delivers_only_once + user = FactoryBot.create(:user) + attributes = { + user: user, + type: 'task', + event: 'new_task_available', + message: 'A task is available.', + dedupe_key: 'new_task_available:task-definition:123' + } + + assert_difference 'Notification.count', 1 do + first = NotificationService.notify(**attributes) + second = NotificationService.notify(**attributes) + + assert_equal first, second + assert_not_nil first.delivered_at + end + + assert_equal 1, NotificationEmailJob.jobs.size + assert_equal 1, PushNotificationDeliveryJob.jobs.size + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_failed_channel_handoff_is_retried_at_least_once + user = FactoryBot.create(:user) + attributes = { + user: user, + type: 'task', + event: 'new_task_available', + message: 'A task is available.', + dedupe_key: 'new_task_available:task-definition:456' + } + failure = ->(_notification_id) { raise StandardError, 'redis unavailable' } + + PushNotificationDeliveryJob.stub(:perform_async, failure) do + NotificationService.notify(**attributes) + end + + notification = Notification.find_by!(dedupe_key: attributes[:dedupe_key]) + assert_nil notification.delivered_at + assert_equal 1, NotificationEmailJob.jobs.size + assert_equal 0, PushNotificationDeliveryJob.jobs.size + assert_equal 0, ActionMailer::Base.deliveries.count + + assert_no_difference 'Notification.count' do + NotificationService.notify(**attributes) + end + + assert_not_nil notification.reload.delivered_at + # The after-commit email belongs to the one created row; retrying only the + # failed async push hand-off must not enqueue a duplicate email. + assert_equal 1, NotificationEmailJob.jobs.size + assert_equal 1, PushNotificationDeliveryJob.jobs.size + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end +end diff --git a/test/services/peer_progress_aggregation_service_test.rb b/test/services/peer_progress_aggregation_service_test.rb new file mode 100644 index 0000000000..fc3a009266 --- /dev/null +++ b/test/services/peer_progress_aggregation_service_test.rb @@ -0,0 +1,538 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PeerProgressAggregationServiceTest < ActiveSupport::TestCase + def setup + @unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 + ) + + @pass_task = create( + :task_definition, + unit: @unit, + target_grade: 0, + outcome_count: 0 + ) + + @credit_task = create( + :task_definition, + unit: @unit, + target_grade: 1, + outcome_count: 0 + ) + + @calculated_at = Time.zone.parse('2026-08-10 10:00:00') + end + + def test_calculates_percentage_for_enrolled_projects_in_the_same_target_grade + projects = create_list( + :project, + 4, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + projects.first(3).each do |project| + create_submitted_task( + project: project, + task_definition: @pass_task + ) + end + + other_grade = create( + :project, + unit: @unit, + target_grade: 1, + enrolled: true + ) + + create_submitted_task( + project: other_grade, + task_definition: @pass_task + ) + + withdrawn = create( + :project, + unit: @unit, + target_grade: 0, + enrolled: false + ) + + create_submitted_task( + project: withdrawn, + task_definition: @pass_task + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 4, snapshot.cohort_size + assert_equal 3, snapshot.submitted_count + assert_equal 75.0, snapshot.submitted_percentage.to_f + assert_equal 1, snapshot.status_counts.fetch('not_started') + assert_equal 3, + snapshot.status_counts.fetch('ready_for_feedback') + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS.sort, + snapshot.status_counts.keys.sort + assert_equal 4, snapshot.status_counts.values.sum + assert_equal @calculated_at, snapshot.calculated_at + end + + def test_aggregates_every_canonical_task_status + projects = create_list( + :project, + PeerProgressDistributionPolicy::STATUS_KEYS.length, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + projects.each_with_index do |project, index| + create( + :task, + project: project, + task_definition: @pass_task, + task_status: TaskStatus.find(index + 1) + ) + end + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal( + PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 1 }, + snapshot.status_counts + ) + end + + def test_rejects_an_unknown_task_status_instead_of_counting_it_as_not_started + unsupported_status = TaskStatus.create!( + id: PeerProgressDistributionPolicy::STATUS_KEYS.length + 1, + name: 'Future lifecycle state', + description: 'Not yet included in the public peer-progress contract' + ) + project = create( + :project, + unit: @unit, + target_grade: 0, + enrolled: true + ) + create( + :task, + project: project, + task_definition: @pass_task, + task_status: unsupported_status + ) + + assert_raises( + PeerProgressAggregationService::UnsupportedTaskStatusError + ) { run_service } + end + + def test_indexes_status_counts_by_task_definition_id_for_snapshot_upsert + create( + :project, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + assert_nothing_raised { run_service } + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + assert_equal 1, snapshot.status_counts.fetch('not_started') + end + + def test_returns_a_genuine_zero_when_the_cohort_exists_but_nobody_has_submitted + create_list( + :project, + 4, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 4, snapshot.cohort_size + assert_equal 0, snapshot.submitted_count + assert_equal 0.0, snapshot.submitted_percentage.to_f + end + + def test_returns_nil_percentage_when_the_cohort_is_empty + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 3 + ) + + assert_equal 0, snapshot.cohort_size + assert_equal 0, snapshot.submitted_count + assert_nil snapshot.submitted_percentage + assert_equal( + PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 0 }, + snapshot.status_counts + ) + end + + def test_only_creates_snapshots_for_tasks_applicable_to_the_target_grade + create( + :project, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create( + :project, + unit: @unit, + target_grade: 1, + enrolled: true + ) + + run_service + + assert PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @pass_task, + target_grade: 0 + ) + + assert_not PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @credit_task, + target_grade: 0 + ) + + assert PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @pass_task, + target_grade: 1 + ) + + assert PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @credit_task, + target_grade: 1 + ) + end + + def test_counts_uploads_regardless_of_the_current_task_status + statuses = [ + TaskStatus.ready_for_feedback, + TaskStatus.complete, + TaskStatus.redo, + TaskStatus.fix_and_resubmit + ] + + projects = create_list( + :project, + statuses.length, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + projects.zip(statuses).each do |project, status| + create_submitted_task( + project: project, + task_definition: @pass_task, + task_status: status + ) + end + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal statuses.length, snapshot.cohort_size + assert_equal statuses.length, snapshot.submitted_count + assert_equal 100.0, snapshot.submitted_percentage.to_f + end + + def test_does_not_mix_projects_or_submissions_from_another_unit + local_projects = create_list( + :project, + 2, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create_submitted_task( + project: local_projects.first, + task_definition: @pass_task + ) + + other_unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 + ) + + other_task = create( + :task_definition, + unit: other_unit, + target_grade: 0, + outcome_count: 0 + ) + + other_projects = create_list( + :project, + 4, + unit: other_unit, + target_grade: 0, + enrolled: true + ) + + other_projects.each do |project| + create_submitted_task( + project: project, + task_definition: other_task + ) + end + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 2, snapshot.cohort_size + assert_equal 50.0, snapshot.submitted_percentage.to_f + assert_not PeerProgressSnapshot.exists?(unit: other_unit) + end + + def test_does_not_create_missing_task_rows + create_list( + :project, + 2, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + assert_no_difference('Task.count') do + run_service + end + end + + def test_updates_existing_snapshots_without_creating_duplicates + projects = create_list( + :project, + 2, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + run_service + snapshot_count = PeerProgressSnapshot.count + + create_submitted_task( + project: projects.first, + task_definition: @pass_task + ) + + PeerProgressAggregationService.call( + unit: @unit, + calculated_at: @calculated_at + 1.hour + ) + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal snapshot_count, PeerProgressSnapshot.count + assert_equal 50.0, snapshot.submitted_percentage.to_f + assert_equal @calculated_at + 1.hour, snapshot.calculated_at + end + + def test_rounds_percentages_to_two_decimal_places + projects = create_list( + :project, + 3, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create_submitted_task( + project: projects.first, + task_definition: @pass_task + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 33.33, snapshot.submitted_percentage.to_f + end + + def test_does_not_count_staff_assessment_without_a_student_upload + project = create( + :project, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create( + :task, + project: project, + task_definition: @pass_task, + task_status: TaskStatus.complete, + file_uploaded_at: nil, + submission_date: @calculated_at - 1.hour, + assessment_date: @calculated_at - 1.hour + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 1, snapshot.cohort_size + assert_equal 0.0, snapshot.submitted_percentage.to_f + end + + def test_counts_a_group_upload_for_each_participating_project + group_unit = create( + :unit, + with_students: true, + student_count: 2, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + task_count: 0, + tutorials: 1, + group_sets: 1, + groups: [{ gs: 0, students: 2 }], + outcome_count: 0 + ) + + group_task = create( + :task_definition, + unit: group_unit, + group_set: group_unit.group_sets.first, + target_grade: 0, + upload_requirements: [], + start_date: 1.day.ago, + outcome_count: 0 + ) + + projects = group_unit.groups.first.projects.to_a + projects.each { |project| project.update!(target_grade: 0) } + + submitting_task = + projects.first.task_for_task_definition(group_task) + + contributions = projects.map do |project| + { + project_id: project.id, + pct: 100 / projects.length, + pts: 3 + } + end + + submitting_task.create_submission_and_trigger_state_change( + submitting_task.student, + true, + contributions, + 'ready_for_feedback' + ) + + PeerProgressAggregationService.call( + unit: group_unit, + calculated_at: @calculated_at + ) + + snapshot = PeerProgressSnapshot.find_by!( + unit: group_unit, + task_definition: group_task, + target_grade: 0 + ) + + assert_equal 2, snapshot.cohort_size + assert_equal 100.0, snapshot.submitted_percentage.to_f + + projects.each do |project| + task = project.tasks.find_by!( + task_definition: group_task + ) + + assert task.file_uploaded_at.present? + end + end + + def run_service + PeerProgressAggregationService.call( + unit: @unit, + calculated_at: @calculated_at + ) + end + + def find_snapshot(task_definition:, target_grade:) + PeerProgressSnapshot.find_by!( + unit: @unit, + task_definition: task_definition, + target_grade: target_grade + ) + end + + def create_submitted_task( + project:, + task_definition:, + task_status: TaskStatus.ready_for_feedback + ) + uploaded_at = @calculated_at - 1.hour + + create( + :task, + project: project, + task_definition: task_definition, + task_status: task_status, + file_uploaded_at: uploaded_at, + submission_date: uploaded_at + ) + end +end diff --git a/test/services/peer_progress_distribution_policy_test.rb b/test/services/peer_progress_distribution_policy_test.rb new file mode 100644 index 0000000000..acc51b4510 --- /dev/null +++ b/test/services/peer_progress_distribution_policy_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PeerProgressDistributionPolicyTest < ActiveSupport::TestCase + test 'returns every lifecycle status in canonical order' do + distribution = PeerProgressDistributionPolicy.build( + status_counts: safe_status_counts, + cohort_size: 25 + ) + + assert_equal PeerProgressDistributionPolicy::STATUS_KEYS, + distribution.pluck(:status) + redo_entry = distribution.find do |entry| + entry.fetch(:status) == 'redo' + end + resubmit_entry = distribution.find do |entry| + entry.fetch(:status) == 'fix_and_resubmit' + end + + assert_equal 10.0, redo_entry.fetch(:percentage) + assert_equal 10.0, resubmit_entry.fetch(:percentage) + end + + test 'suppresses a jointly identifying vector even though each bucket is independently ambiguous' do + counts = empty_status_counts.merge( + 'not_started' => 6, + 'complete' => 18 + ) + + assert_nil PeerProgressDistributionPolicy.build( + status_counts: counts, + cohort_size: 24 + ) + end + + test 'rejects missing extra negative and inconsistent counts' do + missing = safe_status_counts.except('redo') + extra = safe_status_counts.merge('unknown' => 0) + negative = safe_status_counts.merge('redo' => -1, 'fail' => 4) + + [missing, extra, negative].each do |counts| + assert_nil PeerProgressDistributionPolicy.build( + status_counts: counts, + cohort_size: 25 + ) + end + + assert_nil PeerProgressDistributionPolicy.build( + status_counts: safe_status_counts, + cohort_size: 26 + ) + end + + test 'binary count ranges match exhaustive quantisation without a cohort cache' do + (21..200).each do |cohort_size| + exhaustive = (0..cohort_size).group_by do |count| + PeerProgressDistributionPolicy.quantised_count_percentage( + count: count, + cohort_size: cohort_size + ) + end + + exhaustive.each do |bucket, counts| + actual = PeerProgressDistributionPolicy.send( + :count_range_for_bucket, + bucket, + cohort_size + ) + + assert_equal counts.min..counts.max, actual + end + end + end + + private + + def empty_status_counts + PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 0 } + end + + def safe_status_counts + empty_status_counts.merge( + 'not_started' => 5, + 'working_on_it' => 5, + 'ready_for_feedback' => 4, + 'fix_and_resubmit' => 3, + 'redo' => 3, + 'complete' => 3, + 'fail' => 2 + ) + end +end diff --git a/test/services/peer_progress_viewer_policy_test.rb b/test/services/peer_progress_viewer_policy_test.rb new file mode 100644 index 0000000000..fcaf3b158d --- /dev/null +++ b/test/services/peer_progress_viewer_policy_test.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PeerProgressViewerPolicyTest < ActiveSupport::TestCase + Snapshot = Struct.new( + :submitted_count, + :cohort_size, + :status_counts, + :calculated_at, + keyword_init: true + ) + ViewerTask = Struct.new( + :task_status_id, + :file_uploaded_at, + :updated_at, + :is_persisted, + keyword_init: true + ) do + def persisted? + is_persisted + end + end + ViewerProject = Struct.new( + :updated_at, + :is_persisted, + keyword_init: true + ) do + def persisted? + is_persisted + end + end + + test 'subtracts the viewers known status upload and cohort membership' do + calculated_at = Time.zone.now + snapshot = Snapshot.new( + cohort_size: 22, + submitted_count: 1, + status_counts: empty_status_counts.merge( + 'not_started' => 21, + 'complete' => 1 + ), + calculated_at: calculated_at + ) + viewer_task = ViewerTask.new( + task_status_id: TaskStatus.complete.id, + file_uploaded_at: 1.hour.ago, + updated_at: calculated_at - 1.minute, + is_persisted: true + ) + + result = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: viewer_project_for(snapshot), + viewer_task: viewer_task + ) + + assert_equal 21, result.fetch(:cohort_size) + assert_equal 0, result.fetch(:submitted_count) + assert_equal 21, result.fetch(:status_counts).fetch('not_started') + assert_equal 0, result.fetch(:status_counts).fetch('complete') + end + + test 'treats a missing viewer task as not started and unsubmitted' do + snapshot = Snapshot.new( + cohort_size: 22, + submitted_count: 21, + status_counts: empty_status_counts.merge( + 'not_started' => 1, + 'complete' => 21 + ), + calculated_at: Time.zone.now + ) + viewer_task = ViewerTask.new( + task_status_id: TaskStatus.not_started.id, + file_uploaded_at: nil, + updated_at: nil, + is_persisted: false + ) + + result = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: viewer_project_for(snapshot), + viewer_task: viewer_task + ) + + assert_equal 21, result.fetch(:cohort_size) + assert_equal 21, result.fetch(:submitted_count) + assert_equal 0, result.fetch(:status_counts).fetch('not_started') + assert_equal 21, result.fetch(:status_counts).fetch('complete') + end + + test 'fails closed when the viewer changed after the snapshot' do + calculated_at = 1.hour.ago + viewer_task = ViewerTask.new( + task_status_id: TaskStatus.not_started.id, + file_uploaded_at: nil, + updated_at: calculated_at + 1.minute, + is_persisted: true + ) + + snapshot = valid_snapshot(calculated_at: calculated_at) + assert_not PeerProgressViewerPolicy.viewer_context_current?( + snapshot: snapshot, + viewer_project: viewer_project_for(snapshot), + viewer_task: viewer_task + ) + assert_nil PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: viewer_project_for(snapshot), + viewer_task: viewer_task + ) + end + + test 'fails closed when project membership may have changed after snapshot' do + snapshot = valid_snapshot(calculated_at: 1.hour.ago) + viewer_project = ViewerProject.new( + updated_at: snapshot.calculated_at + 1.minute, + is_persisted: true + ) + + assert_not PeerProgressViewerPolicy.viewer_context_current?( + snapshot: snapshot, + viewer_project: viewer_project, + viewer_task: missing_viewer_task + ) + assert_nil PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: viewer_project, + viewer_task: missing_viewer_task + ) + end + + test 'fails closed for an incomplete exact upload aggregate' do + snapshot = valid_snapshot + snapshot.submitted_count = nil + + assert_nil PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: viewer_project_for(snapshot), + viewer_task: missing_viewer_task + ) + end + + test 'fails closed for a lifecycle status outside the canonical contract' do + viewer_task = missing_viewer_task + viewer_task.task_status_id = 16 + + snapshot = valid_snapshot + assert_nil PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: viewer_project_for(snapshot), + viewer_task: viewer_task + ) + end + + private + + def valid_snapshot(calculated_at: Time.zone.now) + Snapshot.new( + cohort_size: 22, + submitted_count: 0, + status_counts: empty_status_counts.merge('not_started' => 22), + calculated_at: calculated_at + ) + end + + def missing_viewer_task + ViewerTask.new( + task_status_id: TaskStatus.not_started.id, + file_uploaded_at: nil, + updated_at: nil, + is_persisted: false + ) + end + + def viewer_project_for(snapshot) + ViewerProject.new( + updated_at: snapshot.calculated_at - 1.minute, + is_persisted: true + ) + end + + def empty_status_counts + PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 0 } + end +end diff --git a/test/services/push_notification_service_test.rb b/test/services/push_notification_service_test.rb new file mode 100644 index 0000000000..081905adcd --- /dev/null +++ b/test/services/push_notification_service_test.rb @@ -0,0 +1,500 @@ +require 'test_helper' +# test_helper does not pull this in, and Object#stub comes from it. Without it +# test_both_timeouts_are_passed_to_the_gem errors with "undefined method 'stub' +# for module WebPush", which it has done since it was written. +require 'minitest/mock' + +# MN-F02: the push channel actually sends. +# +# These tests go through the real web-push gem and stub the HTTP call to the +# push service, rather than stubbing WebPush itself. That is deliberate: the +# part most likely to be wired up wrong is the gem call and the payload, and a +# stub of WebPush.payload_send would prove neither. +class PushNotificationServiceTest < ActiveSupport::TestCase + # A throwaway VAPID pair, generated for these tests. The matching browser key + # pair lives in the push_subscription factory, which is where the real + # prime256v1 public key the gem needs to encrypt against now comes from. + VAPID_PUBLIC = 'BOs-KbIoHK7gUIX3i2_uEuDoouj-GKxB-mY9CRmLNmd4Wn-SSl254E1g6jR1ukL3e37p8uCpaMjOvfAB0BwzvSI='.freeze + VAPID_PRIVATE = '_NFIWSUTdCdLJJFh87pf4ekQLmNYqsweZ4288NpVZaY='.freeze + + # Fixed rather than sequenced, because every test here has to stub the exact + # URL the gem will post to. + ENDPOINT = 'https://fcm.googleapis.com/fcm/send/test-browser'.freeze + + setup do + @user = FactoryBot.create(:user, :student) + @notification = Notification.create!( + user: @user, + notification_type: 'feedback', + event: 'task_comment_created', + message: 'Andrew Cain commented on 1.1P in COS10001.', + link: '/projects/2/dashboard/1.1P' + ) + end + + # Set the keys for the block and put the environment back exactly as it was. + # + # Restoring rather than deleting matters: the development container now has + # real values in its environment, so a test that deleted them would leave the + # process different from how it found it, and a test that assumed they were + # absent to begin with would pass in CI and fail on a developer's machine. + def with_env(values) + previous = values.keys.index_with { |key| ENV.fetch(key, nil) } + values.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + yield + ensure + previous.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + + def with_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => VAPID_PUBLIC, 'DOUBTFIRE_VAPID_PRIVATE_KEY' => VAPID_PRIVATE }, &) + end + + def without_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => nil, 'DOUBTFIRE_VAPID_PRIVATE_KEY' => nil }, &) + end + + def create_subscription(endpoint: ENDPOINT) + FactoryBot.create(:push_subscription, user: @user, endpoint: endpoint) + end + + # Read out of the built payload rather than off tag_for, so these tests fail + # if the tag stops reaching the part that is actually sent. + def tag_for(notification) + JSON.parse(PushNotificationService.payload_for(notification))['notification']['tag'] + end + + def click_data(notification = @notification) + JSON.parse(PushNotificationService.payload_for(notification)).dig('notification', 'data') + end + + def test_nothing_is_sent_when_the_vapid_keys_are_missing + create_subscription + + # No WebMock stub is registered, so any outbound request would raise. + without_keys do + assert_not PushNotificationService.configured? + assert_nothing_raised { PushNotificationService.deliver(@notification) } + end + end + + def test_a_notification_is_pushed_to_the_subscribed_browser + create_subscription + request = stub_request(:post, ENDPOINT).to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested request + end + + def test_every_subscribed_browser_is_pushed_to + create_subscription(endpoint: "#{ENDPOINT}-one") + create_subscription(endpoint: "#{ENDPOINT}-two") + + first = stub_request(:post, "#{ENDPOINT}-one").to_return(status: 201) + second = stub_request(:post, "#{ENDPOINT}-two").to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested first + assert_requested second + end + + def test_nothing_is_sent_when_the_user_has_no_browsers_registered + with_keys { assert_nothing_raised { PushNotificationService.deliver(@notification) } } + end + + def test_a_gone_subscription_is_deleted + create_subscription + stub_request(:post, ENDPOINT).to_return(status: 410) + + assert_difference 'PushSubscription.count', -1 do + with_keys { PushNotificationService.deliver(@notification) } + end + end + + def test_a_not_found_subscription_is_deleted + create_subscription + stub_request(:post, ENDPOINT).to_return(status: 404) + + assert_difference 'PushSubscription.count', -1 do + with_keys { PushNotificationService.deliver(@notification) } + end + end + + # A rate limit or an outage is temporary. Deleting on those would silently + # unsubscribe people the first time a push service had a bad day. + def test_a_temporary_push_service_failure_is_raised_for_retry_and_keeps_the_subscription + create_subscription + stub_request(:post, ENDPOINT).to_return(status: 429) + + assert_no_difference 'PushSubscription.count' do + with_keys do + assert_raises(PushNotificationService::DeliveryError) do + PushNotificationService.deliver(@notification) + end + end + end + end + + def test_a_temporary_failure_does_not_stop_the_other_browsers + failing_endpoint = "#{ENDPOINT}-unavailable" + healthy_endpoint = "#{ENDPOINT}-healthy" + create_subscription(endpoint: failing_endpoint) + create_subscription(endpoint: healthy_endpoint) + + stub_request(:post, failing_endpoint).to_return(status: 503) + healthy = stub_request(:post, healthy_endpoint).to_return(status: 201) + + with_keys do + assert_raises(PushNotificationService::DeliveryError) do + PushNotificationService.deliver(@notification) + end + end + + assert_requested healthy + assert_equal 2, @user.push_subscriptions.reload.count + end + + def test_one_dead_browser_does_not_stop_the_others + create_subscription(endpoint: "#{ENDPOINT}-dead") + create_subscription(endpoint: "#{ENDPOINT}-alive") + + stub_request(:post, "#{ENDPOINT}-dead").to_return(status: 410) + alive = stub_request(:post, "#{ENDPOINT}-alive").to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested alive + assert_equal ["#{ENDPOINT}-alive"], @user.push_subscriptions.reload.map(&:endpoint) + end + + # Angular's ngsw-worker.js only displays a push if the payload has a top level + # "notification" key. Anything else needs a hand written service worker. + def test_the_payload_has_the_shape_angulars_service_worker_expects + payload = JSON.parse(PushNotificationService.payload_for(@notification)) + + assert payload.key?('notification'), 'ngsw-worker.js will ignore a payload without this key' + + body = payload['notification'] + assert_equal '/assets/icons/android-chrome-192x192.png', body['icon'] + assert_equal '/assets/icons/android-chrome-192x192.png', body['badge'] + + assert_equal 'Andrew Cain commented on 1.1P in COS10001.', body['body'] + assert_equal '/projects/2/dashboard/1.1P', body.dig('data', 'link') + assert_equal @notification.id, body.dig('data', 'notification_id') + assert_equal 'focusLastFocusedOrOpen', + body.dig('data', 'onActionClick', 'default', 'operation') + assert_equal '/projects/2/dashboard/1.1P', + body.dig('data', 'onActionClick', 'default', 'url') + assert_not_nil body['title'] + end + + def test_v2_events_with_sensitive_detail_use_reviewed_lock_screen_copy + sensitive_canary = 'PRIVATE-NAME-AND-SCHEDULE-7429' + expected_bodies = { + 'tutorial_changed' => 'Your tutorial details changed.', + 'group_membership_changed' => 'Your group membership changed.', + 'task_submitted' => 'A task is ready for marking.', + 'portfolio_received' => 'Your portfolio submission was received.' + } + + expected_bodies.each do |event, expected_body| + notification = Notification.create!( + user: @user, + notification_type: 'general', + event: event, + message: "#{sensitive_canary} appeared in the rich channel copy.", + link: '/notifications' + ) + + body = JSON.parse(PushNotificationService.payload_for(notification)) + .dig('notification', 'body') + + assert_equal expected_body, body, event + assert_not_includes body, sensitive_canary, event + end + end + + def test_click_payload_preserves_every_approved_route_family + routes = [ + '/notifications', + '/projects/2/dashboard', + '/projects/2/groups', + '/projects/2/dashboard/1.1P', + '/projects/2/dashboard/1.1P/feedback', + '/projects/2/dashboard/T1.1', + '/projects/2/dashboard/HD1.2', + '/projects/2/dashboard/10.1H', + '/projects/2/dashboard/A15', + '/projects/2/dashboard/TASK1', + '/projects/2/dashboard/P-2.21', + '/projects/2/dashboard/D-9.568', + '/projects/2/dashboard/C-4.602' + ] + + routes.each do |route| + @notification.link = route + data = click_data + + assert_equal route, data['link'] + assert_equal route, data.dig('onActionClick', 'default', 'url') + assert_equal 'focusLastFocusedOrOpen', + data.dig('onActionClick', 'default', 'operation') + end + end + + def test_click_payload_falls_back_for_missing_malformed_external_and_encoded_links + invalid_links = [ + nil, + '', + ' ', + 'http://example.test/projects/2/dashboard', + 'https://example.test/projects/2/dashboard', + 'mailto:student@example.test', + 'javascript:alert(1)', + 'data:text/html,unsafe', + 'file:///etc/passwd', + '//example.test/projects/2/dashboard', + '\\example.test\\projects\\2', + '/projects/2\\dashboard', + '/%2f%2fexample.test', + '/%5cexample.test', + '/projects/2/dashboard/%31.1P', + '/projects/2/dashboard/1.1P%3ftoken%3dsecret', + '/%252f%252fexample.test', + '/projects/2/dashboard/%', + '/projects/0/dashboard', + '/projects/2/dashboard/', + '/projects/2/dashboard/1.1P/extra', + '/projects/2/dashboard/1.1P/feedback/extra', + '/projects/2/dashboard/1.1P?token=secret', + '/projects/2/dashboard/1.1P#feedback', + "/projects/2/dashboard/#{'A1' * 20}", + '/units/2', + '/home' + ] + + invalid_links.each do |link| + @notification.link = link + data = click_data + + assert_equal '/notifications', data['link'], "expected fallback for #{link.inspect}" + assert_equal '/notifications', data.dig('onActionClick', 'default', 'url') + end + end + + def test_an_unsafe_link_is_not_copied_into_the_push_tag + @notification.link = 'https://example.test/unsafe' + + tag = tag_for(@notification) + assert_equal "notification-#{@notification.id}", tag + assert_not_includes tag, 'example.test' + end + + def test_click_payload_preserves_bounded_task_segments_without_guessing_their_format + routes = [ + '/projects/2/dashboard/85', + '/projects/2/dashboard/Alice1', + '/projects/2/dashboard/BOB1', + '/projects/2/dashboard/1.1ALICE', + '/projects/2/dashboard/1.1BOB', + '/projects/2/dashboard/feedback1', + '/projects/2/dashboard/token123', + '/projects/2/dashboard/mark85' + ] + + routes.each do |route| + @notification.link = route + data = click_data + + assert_equal route, data['link'] + assert_equal route, data.dig('onActionClick', 'default', 'url') + end + end + + def test_two_notifications_about_the_same_thing_share_a_tag + second = Notification.create!( + user: @user, + notification_type: @notification.notification_type, + event: @notification.event, + message: 'Andrew Cain commented on 1.1P in COS10001.', + link: @notification.link + ) + + assert_equal tag_for(@notification), tag_for(second) + end + + # The half that is easy to get wrong in the other direction. A tag shared by + # unrelated notifications does not tidy anything up, it hides one behind + # another and the user never sees it. + def test_notifications_about_different_things_do_not_share_a_tag + elsewhere = Notification.create!( + user: @user, + notification_type: @notification.notification_type, + event: @notification.event, + message: 'Andrew Cain commented on 2.1P in COS10001.', + link: '/projects/2/dashboard/2.1P' + ) + + assert_not_equal tag_for(@notification), tag_for(elsewhere) + end + + # The tag names the conversation, so it cannot contain anything that changes + # between messages in it. Using the notification id would be unique every time + # and would collapse nothing at all, which is the whole ticket undone. + def test_the_tag_does_not_change_between_notifications_in_a_burst + tags = 3.times.map do |index| + burst = Notification.create!( + user: @user, + notification_type: @notification.notification_type, + event: @notification.event, + message: "Andrew Cain commented on 1.1P in COS10001. (#{index})", + link: @notification.link + ) + + tag_for(burst) + end + + # Three different ids, one tag. If the id were part of it there would be + # three, so this is the assertion that pins the id out of the tag. + assert_equal 1, tags.uniq.length, tags.inspect + end + + # The one that decides between keying on the event and keying on + # notification_type. notification_type is only the preference category, so + # task_due_date_changed, task_status_changed, new_task_available and + # task_due_soon are all `task`. Keyed on that, a status change would silently + # take the place of a deadline alert about the same task, which is the failure + # this ticket exists to avoid rather than one to introduce. + def test_different_events_in_the_same_category_do_not_share_a_tag + deadline = Notification.create!( + user: @user, + notification_type: 'task', + event: 'task_due_date_changed', + message: 'The due date for 1.1P in COS10001 has changed.', + link: @notification.link + ) + due_soon = Notification.create!( + user: @user, + notification_type: 'task', + event: 'task_due_soon', + message: '1.1P in COS10001 is due soon.', + link: @notification.link + ) + + assert_equal deadline.notification_type, due_soon.notification_type + assert_not_equal tag_for(deadline), tag_for(due_soon) + end + + # link is nullable on the api, and there is nothing else to be about. Sharing + # one empty tag between every notification that happens to have no link would + # hide all but the newest of them. + def test_a_notification_with_no_link_collapses_with_nothing + first = Notification.create!( + user: @user, notification_type: 'general', event: 'system_announcement', message: 'One' + ) + second = Notification.create!( + user: @user, notification_type: 'general', event: 'system_announcement', message: 'Two' + ) + + assert_not_equal tag_for(first), tag_for(second) + assert tag_for(first).present? + end + + # Silent replacement is the point. renotify: true puts the sound and the + # vibration back on every message in the burst the tag exists to quieten. + def test_a_replacement_does_not_buzz_again + body = JSON.parse(PushNotificationService.payload_for(@notification))['notification'] + + assert_equal false, body['renotify'] + end + + # MN-D01 and MN-D05 own the wording. This ticket only adds the tag, so a + # change to either of those here is somebody else's work being overwritten. + def test_the_title_and_body_are_left_alone + body = JSON.parse(PushNotificationService.payload_for(@notification))['notification'] + + assert_equal 'Andrew Cain commented on 1.1P in COS10001.', body['body'] + assert_equal Doubtfire::Application.config.institution[:product_name], body['title'] + end + + def test_a_long_message_is_trimmed_rather_than_rejected_by_the_push_service + @notification.update!(message: 'a' * 500) + + body = JSON.parse(PushNotificationService.payload_for(@notification))['notification']['body'] + + assert_operator body.length, :<=, PushNotificationService::MAX_BODY_LENGTH + end + + # The shared fan-out queues an ID-only job, and the worker calls this service + # with no per-event push wiring. + def test_raising_a_notification_through_the_hub_queues_and_delivers_a_push + create_subscription + request = stub_request(:post, ENDPOINT).to_return(status: 201) + notification = NotificationService.notify( + user: @user, + type: 'feedback', + event: 'task_comment_created', + message: 'Raised through the hub.', + link: '/projects/2/dashboard/1.1P' + ) + + assert_equal [notification.id], PushNotificationDeliveryJob.jobs.last['args'] + assert_not_requested request + + with_keys { PushNotificationDeliveryJob.new.perform(notification.id) } + assert_requested request + end + + # Rows written before PushSubscription validated the endpoint were never + # checked, so the service refuses at send time as well. This is the line that + # actually stops a stored bad endpoint being used, so it is tested by writing + # a row that skips validation, the way an old row would look. + def test_a_stored_endpoint_that_is_not_a_push_service_is_never_requested + subscription = FactoryBot.build(:push_subscription, user: @user, endpoint: 'https://169.254.169.254/latest/meta-data/') + subscription.save!(validate: false) + + # No WebMock stub is registered for that host, so an outbound request would + # raise rather than pass silently. + with_keys { PushNotificationService.deliver(@notification) } + + assert_not_requested :post, 'https://169.254.169.254/latest/meta-data/' + assert subscription.reload.persisted?, 'a refused endpoint should be left alone, not treated as dead' + end + + def test_a_refused_endpoint_does_not_stop_the_other_browsers + FactoryBot + .build(:push_subscription, user: @user, endpoint: 'https://10.0.0.5/internal') + .save!(validate: false) + create_subscription + good = stub_request(:post, ENDPOINT).to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested good + end + + # Timeouts are Net::HTTP settings rather than anything visible on the wire, so + # WebMock cannot see them. This one test stubs the gem instead of the HTTP + # call, which is the opposite of what the rest of this file does on purpose. + # + # It is worth the exception because web-push sets no timeouts of its own, and + # because the gem only applies read_timeout when open_timeout is also present + # (lib/web_push/request.rb line 15), so passing one without the other silently + # does nothing. + def test_delivery_limits_and_timeouts_are_passed_to_the_gem + create_subscription + captured = nil + + WebPush.stub(:payload_send, ->(**args) { captured = args }) do + with_keys { PushNotificationService.deliver(@notification) } + end + + assert_equal PushNotificationService::OPEN_TIMEOUT, captured[:open_timeout] + assert_equal PushNotificationService::READ_TIMEOUT, captured[:read_timeout] + assert_equal PushNotificationService::SSL_TIMEOUT, captured[:ssl_timeout] + assert_equal PushNotificationService::MESSAGE_TTL, captured[:ttl] + assert_equal PushNotificationService::MESSAGE_URGENCY, captured[:urgency] + 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/aggregate_peer_progress_job_test.rb b/test/sidekiq/aggregate_peer_progress_job_test.rb new file mode 100644 index 0000000000..07dac1945e --- /dev/null +++ b/test/sidekiq/aggregate_peer_progress_job_test.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' + +class AggregatePeerProgressJobTest < ActiveSupport::TestCase + def setup + @active_unit = create_minimal_unit(active: true) + @inactive_unit = create_minimal_unit(active: false) + @disabled_unit = create_minimal_unit( + active: true, + peer_progress_enabled: false + ) + @calculated_at = Time.zone.parse('2026-08-10 23:45:00') + end + + def test_aggregates_the_requested_active_unit + calls = [] + + travel_to @calculated_at do + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, calculated_at:| + calls << { + unit: unit, + calculated_at: calculated_at + } + [] + end + ) do + AggregatePeerProgressJob.new.perform(@active_unit.id) + end + end + + assert_equal 1, calls.length + assert_equal @active_unit, calls.first[:unit] + assert_equal @calculated_at, calls.first[:calculated_at] + end + + def test_enqueues_one_job_for_each_enabled_active_unit_when_no_unit_id_is_given + Sidekiq::Job.clear_all + + expected_unit_ids = + Unit.active_units + .where(peer_progress_enabled: true) + .order(:id) + .pluck(:id) + + assert_difference( + -> { AggregatePeerProgressJob.jobs.size }, + expected_unit_ids.length + ) do + AggregatePeerProgressJob.new.perform + end + + actual_unit_ids = + AggregatePeerProgressJob.jobs + .last(expected_unit_ids.length) + .map { |job| job['args'].first } + .sort + + assert_equal expected_unit_ids, actual_unit_ids + assert_not_includes actual_unit_ids, @inactive_unit.id + assert_not_includes actual_unit_ids, @disabled_unit.id + end + + def test_failure_for_one_unit_does_not_prevent_another_unit_job + other_unit = create_minimal_unit(active: true) + successful_unit_ids = [] + + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, **_kwargs| + if unit.id == @active_unit.id + raise StandardError, 'first unit failed' + end + + successful_unit_ids << unit.id + [] + end + ) do + assert_raises(StandardError) do + AggregatePeerProgressJob.new.perform(@active_unit.id) + end + + AggregatePeerProgressJob.new.perform(other_unit.id) + end + + assert_equal [other_unit.id], successful_unit_ids + end + + def test_skips_a_requested_inactive_unit + calls = [] + + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, calculated_at:| + calls << [unit, calculated_at] + [] + end + ) do + AggregatePeerProgressJob.new.perform(@inactive_unit.id) + end + + assert_empty calls + end + + def test_skips_a_requested_unit_with_peer_progress_disabled + calls = [] + + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, calculated_at:| + calls << [unit, calculated_at] + [] + end + ) do + AggregatePeerProgressJob.new.perform(@disabled_unit.id) + end + + assert_empty calls + end + + def test_sanitizes_the_error_when_requested_unit_does_not_exist + missing_unit_id = Unit.maximum(:id).to_i + 10_000 + + error = assert_raises(AggregatePeerProgressJob::AggregationError) do + AggregatePeerProgressJob.new.perform(missing_unit_id) + end + + assert_equal( + "Peer progress aggregation failed for unit_id=#{missing_unit_id}: " \ + 'ActiveRecord::RecordNotFound', + error.message + ) + assert_nil error.cause + end + + def test_sanitizes_aggregation_errors_before_sidekiq_handles_them + sensitive_message = + 'peer_username=private-peer name=Private Student ' \ + 'email=private-peer@example.invalid student_id=987654321' + start_log = + "Starting peer progress aggregation for unit_id=#{@active_unit.id}..." + failure_message = + "Peer progress aggregation failed for unit_id=#{@active_unit.id}: " \ + 'StandardError' + logger = Minitest::Mock.new + logger.expect(:info, nil, [start_log]) + logger.expect(:error, nil, [failure_message]) + job = AggregatePeerProgressJob.new + + PeerProgressAggregationService.stub( + :call, + lambda do |**_kwargs| + raise StandardError, sensitive_message + end + ) do + error = assert_raises(AggregatePeerProgressJob::AggregationError) do + job.stub(:logger, logger) do + job.perform(@active_unit.id) + end + end + + assert_equal failure_message, error.message + assert_nil error.cause + assert_not_includes error.message, sensitive_message + assert_not_includes error.full_message, sensitive_message + end + + assert_mock logger + assert_equal 3, AggregatePeerProgressJob.get_sidekiq_options['retry'] + end + + def test_enqueues_only_the_unit_id + assert_difference -> { AggregatePeerProgressJob.jobs.size }, 1 do + AggregatePeerProgressJob.perform_async(@active_unit.id) + end + + queued_job = AggregatePeerProgressJob.jobs.last + + assert_equal [@active_unit.id], queued_job['args'] + end + + def test_creates_a_snapshot_through_the_real_aggregation_service + task_definition = create( + :task_definition, + unit: @active_unit, + target_grade: 0, + outcome_count: 0 + ) + + projects = create_list( + :project, + 2, + unit: @active_unit, + target_grade: 0, + enrolled: true + ) + + create( + :task, + project: projects.first, + task_definition: task_definition, + task_status: TaskStatus.ready_for_feedback, + file_uploaded_at: @calculated_at - 1.hour, + submission_date: @calculated_at - 1.hour + ) + + travel_to @calculated_at do + AggregatePeerProgressJob.new.perform(@active_unit.id) + end + + snapshot = PeerProgressSnapshot.find_by!( + unit: @active_unit, + task_definition: task_definition, + target_grade: 0 + ) + + assert_equal 2, snapshot.cohort_size + assert_equal 50.0, snapshot.submitted_percentage.to_f + assert_equal @calculated_at, snapshot.calculated_at + end + + private + + def create_minimal_unit(active:, peer_progress_enabled: true) + create( + :unit, + active: active, + peer_progress_enabled: peer_progress_enabled, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 + ) + end +end diff --git a/test/sidekiq/execute_communication_set_job_test.rb b/test/sidekiq/execute_communication_set_job_test.rb index 8ec1afb8b3..764fc04ef7 100644 --- a/test/sidekiq/execute_communication_set_job_test.rb +++ b/test/sidekiq/execute_communication_set_job_test.rb @@ -54,4 +54,133 @@ def test_task_comment_action_adds_a_comment_to_each_selected_students_task assert_equal 'Please review Ada for ' + unit.code, comment_one.comment assert_equal 'Please review Grace for ' + unit.code, comment_two.comment end + + # Builds a unit with three enrolled students and a set that emails all of them. + def email_set_with_three_students + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 1, + stream_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 1 + ) + + campus = Campus.first + projects = %w[Ada Grace Katherine].map do |first_name| + student = FactoryBot.create(:user, :student) + student.update!(first_name: first_name) + unit.enrol_student(student, campus) + end + + communication_set = unit.communication_sets.create!(name: 'Email Set', active: true) + communication_rule = communication_set.communication_rules.create!( + name: 'Email Rule', + operator: 'and', + position: 0 + ) + communication_rule.communication_actions.create!( + type: 'EmailStudentAction', + subject: 'A message about {{unit.code}}', + body: 'Hello {{student.first_name}}' + ) + + [communication_set, projects] + end + + # Replaces the mailer for the duration of the block so that the nth delivery + # raises the way an unroutable address does. + def with_delivery_failing_on(nth) + original = CommunicationsMailer.method(:communication_email) + calls = 0 + + CommunicationsMailer.define_singleton_method(:communication_email) do |**kwargs| + calls += 1 + if calls == nth + failing = Object.new + failing.define_singleton_method(:deliver_now) { raise 'mailbox unavailable' } + failing + else + original.call(**kwargs) + end + end + + yield + ensure + CommunicationsMailer.singleton_class.send(:remove_method, :communication_email) + CommunicationsMailer.define_singleton_method(:communication_email, original) + end + + # Runs the job while capturing the payload it would store, which is the record + # a convenor sees of the run. + def perform_capturing_result(communication_set_id) + job = ExecuteCommunicationSetJob.new + captured = nil + job.define_singleton_method(:store) { |payload| captured = payload } + job.perform(communication_set_id) + captured + end + + # One bad address used to abort the run, and the retry started again from the + # first student, so everyone already emailed got a second copy. + def test_one_failed_delivery_does_not_stop_the_rest_of_the_run + communication_set, projects = email_set_with_three_students + ActionMailer::Base.deliveries.clear + + result = with_delivery_failing_on(2) do + perform_capturing_result(communication_set.id) + end + + assert_equal 2, ActionMailer::Base.deliveries.count + + email_rows = result[:result][:actions].select { |row| row[:action_type] == 'EmailStudentAction' } + failed = email_rows.select { |row| row[:status] == 'failed' } + + assert_equal 1, failed.count + assert_equal 2, email_rows.count { |row| row[:status] == 'sent' } + assert_equal 'mailbox unavailable', failed.first[:reason] + assert_includes projects.map(&:id), failed.first[:project_id] + + rule = communication_set.communication_rules.first + csv = ExecuteCommunicationSetJob.new.send(:build_action_log_csv, rule, projects, email_rows) + failed_csv_row = CSV.parse(csv, headers: true).find { |row| row['status'] == 'failed' } + + assert_not_nil failed_csv_row + assert_equal( + "Failed to send email to #{failed.first[:recipient_email]}: mailbox unavailable", + failed_csv_row['details'] + ) + end + + # The check on an over-eager rescue. Nothing about a clean run changes. + def test_a_run_with_no_failures_is_unchanged + communication_set, projects = email_set_with_three_students + ActionMailer::Base.deliveries.clear + + result = perform_capturing_result(communication_set.id) + + assert_equal 3, ActionMailer::Base.deliveries.count + + email_rows = result[:result][:actions].select { |row| row[:action_type] == 'EmailStudentAction' } + assert_equal 3, email_rows.count { |row| row[:status] == 'sent' } + assert_empty email_rows.select { |row| row[:status] == 'failed' } + assert_equal projects.length, email_rows.length + end + + # Documents honestly what this change does not fix. There is still no record of + # who has already been sent to, so running the set again mails everyone again. + # A per-recipient delivery ledger is a separate ticket. + def test_a_second_run_still_mails_everyone_again + communication_set, = email_set_with_three_students + ActionMailer::Base.deliveries.clear + + with_delivery_failing_on(2) do + perform_capturing_result(communication_set.id) + end + assert_equal 2, ActionMailer::Base.deliveries.count + + perform_capturing_result(communication_set.id) + assert_equal 5, ActionMailer::Base.deliveries.count + end end diff --git a/test/sidekiq/notification_email_job_test.rb b/test/sidekiq/notification_email_job_test.rb new file mode 100644 index 0000000000..a3369b34a5 --- /dev/null +++ b/test/sidekiq/notification_email_job_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' + +class NotificationEmailJobTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + end + + def test_perform_delivers_the_notification_email + notification = FactoryBot.create( + :notification, + event: 'general', + message: 'EN-F03 job delivery test.' + ) + + assert_difference( + -> { ActionMailer::Base.deliveries.count }, + 1 + ) do + NotificationEmailJob.new.perform(notification.id) + end + + mail = ActionMailer::Base.deliveries.last + body = if mail.multipart? + mail.parts.map { |part| part.body.decoded }.join("\n") + else + mail.body.decoded + end + expected_subject = "#{Doubtfire::Application.config.institution[:product_name]}: New notification" + + assert_equal [notification.user.email], mail.to + assert_equal expected_subject, mail.subject + assert_includes body, notification.message + end + + def test_missing_notification_is_raised_so_a_pre_commit_race_is_retried + assert_no_difference( + -> { ActionMailer::Base.deliveries.count } + ) do + assert_raises(ActiveRecord::RecordNotFound) do + NotificationEmailJob.new.perform(-1) + end + end + end + + def test_the_job_runs_on_the_mailers_queue + # Student facing email must not queue behind PDF and CSV work on default. + # The worker has to be listening on this queue, see doubtfire-deploy#10. + assert_equal 'mailers', NotificationEmailJob.get_sidekiq_options['queue'].to_s + end + + def test_no_delivery_when_the_preference_was_turned_off_after_queueing + user = FactoryBot.create(:user, receive_feedback_notifications: true) + notification = FactoryBot.create( + :notification, + :feedback, + user: user, + event: 'task_comment_created', + message: 'Queued while the category was still on.' + ) + + # retry: 3 means the job can run well after it was queued. + user.update!(receive_feedback_notifications: false) + + assert_no_difference( + -> { ActionMailer::Base.deliveries.count } + ) do + NotificationEmailJob.new.perform(notification.id) + end + end + + def test_a_type_without_a_preference_is_still_delivered + user = FactoryBot.create( + :user, + receive_task_notifications: false, + receive_feedback_notifications: false, + receive_portfolio_notifications: false + ) + notification = FactoryBot.create( + :notification, + user: user, + event: 'general', + message: 'General notices ignore the category toggles.' + ) + + assert_difference( + -> { ActionMailer::Base.deliveries.count }, + 1 + ) do + NotificationEmailJob.new.perform(notification.id) + end + end + + def test_delivery_failure_is_raised_so_sidekiq_can_retry + notification = FactoryBot.create( + :notification, + event: 'general' + ) + failing_delivery = Class.new do + def deliver_now + raise 'smtp unavailable' + end + end.new + + NotificationsMailer.stub(:single_notification, ->(_notification) { failing_delivery }) do + error = assert_raises(RuntimeError) do + NotificationEmailJob.new.perform(notification.id) + end + assert_equal 'smtp unavailable', error.message + end + end + + def test_async_payload_contains_only_the_notification_id + notification = FactoryBot.create( + :notification, + event: 'general' + ) + jid = NotificationEmailJob.perform_async(notification.id) + job = NotificationEmailJob.jobs.find do |candidate| + candidate['jid'] == jid + end + + assert_not_nil job + assert_equal 'NotificationEmailJob', job['class'] + assert_equal 'mailers', job['queue'] + assert_equal [notification.id], job['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end +end diff --git a/test/sidekiq/push_notification_delivery_job_test.rb b/test/sidekiq/push_notification_delivery_job_test.rb new file mode 100644 index 0000000000..adfcfdcf7f --- /dev/null +++ b/test/sidekiq/push_notification_delivery_job_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' + +class PushNotificationDeliveryJobTest < ActiveSupport::TestCase + VAPID_PUBLIC = 'BOs-KbIoHK7gUIX3i2_uEuDoouj-GKxB-mY9CRmLNmd4Wn-SSl254E1g6jR1ukL3e37p8uCpaMjOvfAB0BwzvSI=' + VAPID_PRIVATE = '_NFIWSUTdCdLJJFh87pf4ekQLmNYqsweZ4288NpVZaY=' + ENDPOINT = 'https://fcm.googleapis.com/fcm/send/job-retry-browser' + + setup do + PushNotificationDeliveryJob.clear + end + + def test_perform_delivers_the_reloaded_notification + notification = FactoryBot.create(:notification, event: 'general') + delivered = nil + + PushNotificationService.stub(:deliver, ->(record) { delivered = record }) do + PushNotificationDeliveryJob.new.perform(notification.id) + end + + assert_equal notification, delivered + end + + def test_missing_notification_is_raised_so_a_pre_commit_race_is_retried + PushNotificationService.stub(:deliver, ->(_record) { flunk 'missing row must not be delivered' }) do + assert_raises(ActiveRecord::RecordNotFound) do + PushNotificationDeliveryJob.new.perform(-1) + end + end + end + + def test_delivery_failure_is_raised_so_sidekiq_can_retry + notification = FactoryBot.create(:notification, event: 'general') + failure = ->(_record) { raise 'push provider unavailable' } + + PushNotificationService.stub(:deliver, failure) do + error = assert_raises(RuntimeError) do + PushNotificationDeliveryJob.new.perform(notification.id) + end + assert_equal 'push provider unavailable', error.message + end + end + + def test_real_provider_failure_reaches_the_sidekiq_retry_boundary + notification = FactoryBot.create(:notification, event: 'general') + FactoryBot.create( + :push_subscription, + user: notification.user, + endpoint: ENDPOINT + ) + stub_request(:post, ENDPOINT).to_return(status: 503) + + with_vapid_keys do + assert_raises(PushNotificationService::DeliveryError) do + PushNotificationDeliveryJob.new.perform(notification.id) + end + end + end + + def test_async_payload_contains_only_the_notification_id + notification = FactoryBot.create(:notification, event: 'general') + jid = PushNotificationDeliveryJob.perform_async(notification.id) + job = PushNotificationDeliveryJob.jobs.find do |candidate| + candidate['jid'] == jid + end + + assert_not_nil job + assert_equal 'PushNotificationDeliveryJob', job['class'] + assert_equal 'notifications', job['queue'] + assert_equal [notification.id], job['args'] + assert_equal 'notifications', PushNotificationDeliveryJob.get_sidekiq_options['queue'].to_s + assert_equal 3, PushNotificationDeliveryJob.get_sidekiq_options['retry'] + end + + private + + def with_vapid_keys + names = %w[DOUBTFIRE_VAPID_PUBLIC_KEY DOUBTFIRE_VAPID_PRIVATE_KEY] + previous = names.index_with { |name| ENV.fetch(name, nil) } + ENV['DOUBTFIRE_VAPID_PUBLIC_KEY'] = VAPID_PUBLIC + ENV['DOUBTFIRE_VAPID_PRIVATE_KEY'] = VAPID_PRIVATE + yield + ensure + previous.each do |name, value| + value.nil? ? ENV.delete(name) : ENV[name] = value + end + end +end diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index e21285fdf3..e335f53220 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -1,21 +1,38 @@ # frozen_string_literal: true require 'test_helper' -class TiiCheckProgressJobTest < ActiveSupport::TestCase +require 'sidekiq_unique_jobs/testing' +class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled + # Clear fake jobs and any unique-job locks left by an earlier test run. + Sidekiq::Job.clear_all Sidekiq::Cron::Job.destroy_all! - Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 6, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + Sidekiq::Cron::Job.load_from_hash!( + YAML.load_file(Rails.root.join('config/schedule.yml')) + ) + + jobs = Sidekiq::Cron::Job.all + peer_progress_job = + jobs.find { |job| job.name == 'aggregate_peer_progress' } + + assert_equal 10, jobs.count, jobs.map(&:name) + assert_not_nil peer_progress_job + assert_equal 'AggregatePeerProgressJob', peer_progress_job.klass + + # Sidekiq::Cron::Job.all returns an Array, not an ActiveRecord relation. + jobs.each(&:enqueue!) - Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count assert_equal 1, TiiCheckProgressJob.jobs.count assert_equal 1, ClearAccessTokensJob.jobs.count assert_equal 1, RefreshModerationFeedbackTimestampsJob.jobs.count + assert_equal 1, AggregatePeerProgressJob.jobs.count assert_equal 1, AggregateTaskCompletionStatsJob.jobs.count assert_equal 1, PollCommunicationSetSchedulesJob.jobs.count + assert_equal 1, SendNewTaskAvailableNotificationsJob.jobs.count + assert_equal 1, SendDueSoonRemindersJob.jobs.count + assert_equal 1, CheckUnitSimilarityJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end - end diff --git a/test/sidekiq/send_due_soon_reminders_job_test.rb b/test/sidekiq/send_due_soon_reminders_job_test.rb new file mode 100644 index 0000000000..86f40a3c36 --- /dev/null +++ b/test/sidekiq/send_due_soon_reminders_job_test.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +require 'test_helper' +# test_helper does not pull this in, and Object#stub comes from it. +require 'minitest/mock' + +class SendDueSoonRemindersJobTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + EVENT = 'task_due_soon' + WINDOW_DAYS = SendDueSoonRemindersJob::WINDOW_DAYS + + setup do + @unit = FactoryBot.create(:unit, task_count: 0) + + # This job sweeps every active unit there is, which is the whole point of + # it. rake db:populate seeds four more, each with a cohort and task + # definitions of its own, and their students would then land in every count + # in this file: the first run of these tests expected 11 notifications and + # got 27. Narrowing the world to the unit under test is what makes a plain + # Notification.count assertion mean what it says. + Unit.where.not(id: @unit.id).update_all(active: false) + + @task_def = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: 0, + start_date: Time.zone.now - 1.week, + target_date: Time.zone.now + 2.days + ) + + ActionMailer::Base.deliveries.clear + end + + # The students who most need a reminder are the ones who have not opened the + # task, and OnTrack has no Task row for them until somebody touches it. A + # sweep that read Task rows would miss exactly those people, and one that + # called task_for_task_definition would silently create a row per student per + # task every morning. + def test_reminds_every_eligible_student_without_creating_tasks + expected = @unit.active_projects.count + + assert_operator expected, :>=, 2 + assert_equal 0, @task_def.tasks.count + + assert_difference 'Notification.count', expected do + assert_no_difference 'Task.count' do + run_job + end + end + + assert_equal expected, ActionMailer::Base.deliveries.count + end + + def test_does_not_remind_about_a_deadline_further_out + @task_def.update!(target_date: Time.zone.now + (WINDOW_DAYS + 1).days) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_reminds_on_the_last_day_of_the_window + @task_def.update!(target_date: Time.zone.now + WINDOW_DAYS.days) + + # The far edge, where an off by one turns the window into WINDOW_DAYS - 1 + # without anything else looking wrong. + assert_difference 'Notification.count', @unit.active_projects.count do + run_job + end + end + + def test_reminds_about_something_due_today + @task_def.update!(target_date: Time.zone.now) + + assert_difference 'Notification.count', @unit.active_projects.count do + run_job + end + end + + def test_does_not_remind_once_the_deadline_has_passed + @task_def.update!(target_date: Time.zone.now - 1.day) + + # Overdue is a different message and a different ticket. A reminder saying + # a task is due soon when it is already late is worse than saying nothing. + assert_no_difference 'Notification.count' do + run_job + end + end + + # This is the whole reason the job carries a duplicate guard. It runs every + # morning and the task is still due soon tomorrow morning. + def test_does_not_remind_the_same_student_twice + run_job + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_does_not_remind_a_withdrawn_student + project = @unit.projects.find_by!(enrolled: false) + project.update!(target_grade: 3) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + end + + def test_does_not_remind_for_an_inactive_unit + @unit.update!(active: false) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_respects_task_notification_preference + project = @unit.active_projects.first + project.student.update!(receive_task_notifications: false) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + end + + def test_does_not_remind_a_student_the_task_is_not_assigned_to + @task_def.update!(target_grade: 3) + below = @unit.active_projects.where('projects.target_grade < 3') + + assert_operator below.count, :>, 0 + + run_job + + below.each do |project| + assert_not Notification.exists?(user: project.student, event: EVENT) + end + end + + # An extension moves the deadline for one student and nobody else, so the + # date has to be read per student rather than off the task definition. + def test_uses_the_students_own_extended_deadline + project = @unit.active_projects.first + project.task_for_task_definition(@task_def).update!(extensions: 1) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + + # Everybody else is still on the original date and still gets one, so this + # is the extension being read and not the whole sweep falling over. + assert Notification.exists?(user: @unit.active_projects.second.student, event: EVENT) + end + + # :discuss and :demonstrate mean the student has submitted and is waiting on a + # tutor, so a reminder is both wrong and the kind of wrong that teaches people + # to ignore notifications. + def test_does_not_remind_about_a_task_that_is_waiting_on_a_tutor + project = @unit.active_projects.first + project.task_for_task_definition(@task_def).update!(task_status: TaskStatus.discuss) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + end + + # A unit with flexible dates gives each target grade its own deadline, and + # that override applies before any Task row exists. Falling back to the task + # definition's own target date for a student with no row is wrong by however + # far apart those two dates are, and it is wrong in both directions: silence + # when something is due in two days, or a reminder a week early. + def test_uses_the_grade_deadline_when_the_unit_has_flexible_dates + @unit.update!(allow_flexible_dates: true) + @task_def.update!(target_date: Time.zone.now + 10.days) + + project = @unit.active_projects.find_by!(target_grade: 2) + @task_def.grade_due_dates.create!( + target_grade: 2, + start_date: Time.zone.now - 1.week, + target_due_date: Time.zone.now + 2.days + ) + + assert_equal 0, @task_def.tasks.count + + run_job + + assert Notification.exists?(user: project.student, event: EVENT) + + # Nobody else moved, so this is the override being read rather than the + # whole window sliding. + other = @unit.active_projects.where.not(target_grade: 2).first + + assert_not Notification.exists?(user: other.student, event: EVENT) + end + + # Logging a failure and carrying on leaves perform successful, Sidekiq + # schedules no retry, and a student whose task is due today is filtered out as + # overdue tomorrow. That reminder is then gone for good. + def test_a_failure_is_raised_so_sidekiq_retries + raising = ->(**_args) { raise 'notification failed' } + + NotificationService.stub(:notify, raising) do + assert_raises(RuntimeError) { run_job } + end + end + + def test_message_and_link_are_privacy_safe + project = @unit.active_projects.first + + run_job + + notification = Notification.find_by!(user: project.student, event: EVENT) + + assert_includes notification.message, @task_def.abbreviation + assert_includes notification.message, @unit.code + + # The date stays out, the same as task_due_date_changed. The row outlives + # the deadline it describes, so "due on the 14th" is wrong a week later + # while "due soon" only ever stops being interesting. + assert_not_includes notification.message, @task_def.target_date.to_date.to_s + assert_not_includes notification.message, @task_def.target_date.to_date.iso8601 + + assert_equal( + "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}", + notification.link + ) + + assert_valid_push_payload( + notification, + expected_link: "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}" + ) + end + + def test_event_specific_template_is_used + run_job + + assert_includes delivered_body, 'The deadline is not included in this email' + end + + def test_the_schedule_entry_points_at_this_job + schedule = YAML.load_file(Rails.root.join('config/schedule.yml')) + + assert_equal( + 'SendDueSoonRemindersJob', + schedule.dig('send_due_soon_reminders', 'class') + ) + end + + private + + def run_job + SendDueSoonRemindersJob.new.perform + NotificationEmailJob.drain + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + return mail.body.decoded unless mail.multipart? + + mail.parts.map { |part| part.body.decoded }.join("\n") + end +end diff --git a/test/sidekiq/send_new_task_available_notifications_job_test.rb b/test/sidekiq/send_new_task_available_notifications_job_test.rb new file mode 100644 index 0000000000..dec07420b8 --- /dev/null +++ b/test/sidekiq/send_new_task_available_notifications_job_test.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' +require 'tempfile' + +class SendNewTaskAvailableNotificationsJobTest < ActiveSupport::TestCase + include ActiveSupport::Testing::TimeHelpers + + setup do + @unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 0, + campus_count: 1, + active: true + ) + Unit.where.not(id: @unit.id).update_all(active: false) + + @student = FactoryBot.create( + :user, + :student, + receive_task_notifications: true + ) + @project = FactoryBot.create( + :project, + unit: @unit, + campus: Campus.first, + user: @student, + enrolled: true, + target_grade: 2 + ) + @release_date = 2.days.from_now.beginning_of_day + @task_definition = FactoryBot.create( + :task_definition, + unit: @unit, + outcome_count: 0, + target_grade: 1, + start_date: @release_date, + target_date: @release_date + 1.week + ) + @task_definition.enable_new_task_notifications! + end + + def test_notifies_on_release_date_without_creating_task_rows + assert_no_difference 'Notification.count' do + run_job + end + + travel_to @release_date.noon do + assert_difference 'Notification.count', 1 do + assert_no_difference 'Task.count' do + run_job + end + end + + assert_no_difference 'Notification.count' do + run_job + end + end + end + + def test_uses_grade_specific_start_date + @unit.update!(allow_flexible_dates: true) + @task_definition.update!(start_date: @release_date + 1.week) + @task_definition.grade_due_dates.create!( + target_grade: @project.target_grade, + start_date: @release_date, + target_due_date: @release_date + 1.week + ) + + travel_to @release_date.noon do + assert_difference 'Notification.count', 1 do + assert_no_difference 'Task.count' do + run_job + end + end + end + end + + def test_skips_a_student_below_the_task_target_grade + @project.update!(target_grade: 0) + + travel_to @release_date.noon do + assert_no_difference 'Notification.count' do + run_job + end + end + end + + def test_uses_student_specific_start_date + @unit.update!(allow_flexible_dates: true) + @task_definition.update!(start_date: @release_date + 1.week) + @project.task_for_task_definition(@task_definition).update!( + target_start_date: @release_date + ) + + travel_to @release_date.noon do + assert_difference 'Notification.count', 1 do + assert_no_difference 'Task.count' do + run_job + end + end + end + end + + def test_catches_up_after_a_missed_release_day + @task_definition.update_column(:created_at, 1.month.ago) + + travel_to (@release_date + 1.day).noon do + assert_difference 'Notification.count', 1 do + run_job + end + end + end + + def test_direct_job_repairs_a_failed_tracking_write_for_future_delivery + @task_definition.update_column(:new_task_notifications_from, nil) + marker_failure = -> { raise StandardError, 'temporary marker failure' } + + assert_difference -> { NewTaskAvailableNotificationJob.jobs.size }, 1 do + @task_definition.stub(:enable_new_task_notifications!, marker_failure) do + NewTaskAvailableNotificationJob.track_and_enqueue(@task_definition) + end + end + + assert_nil @task_definition.reload.new_task_notifications_from + assert_no_difference 'Notification.count' do + NewTaskAvailableNotificationJob.new.perform(@task_definition.id) + end + assert_not_nil @task_definition.reload.new_task_notifications_from + + travel_to @release_date.noon do + assert_difference 'Notification.count', 1 do + run_job + end + end + end + + def test_future_csv_import_is_tracked_and_notified_on_release + @task_definition.update_column(:new_task_notifications_from, nil) + source_unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 0, + campus_count: 0, + active: false, + start_date: @unit.start_date, + end_date: @unit.end_date + ) + abbreviation = "CSV#{SecureRandom.hex(3)}" + FactoryBot.create( + :task_definition, + unit: source_unit, + outcome_count: 0, + abbreviation: abbreviation, + target_grade: 1, + start_date: @release_date, + target_date: @release_date + 1.week + ) + csv = Tempfile.new(['future-task', '.csv']) + csv.write(source_unit.task_definitions_csv) + csv.rewind + + result = @unit.import_tasks_from_csv(csv) + assert_empty result[:errors], result.inspect + + imported = @unit.task_definitions.find_by!(abbreviation: abbreviation) + assert_not_nil imported.new_task_notifications_from + assert_no_difference 'Notification.count' do + assert_no_difference 'Task.count' do + NewTaskAvailableNotificationJob.new.perform(imported.id) + end + end + + travel_to @release_date.noon do + assert_difference 'Notification.count', 1 do + assert_no_difference 'Task.count' do + run_job + end + end + end + ensure + csv&.close! + source_unit&.destroy + end + + def test_does_not_backfill_a_historical_task + @task_definition.update_columns( + created_at: 1.month.ago, + start_date: Time.zone.today - 1.day, + new_task_notifications_from: Time.current + ) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_does_not_backfill_a_recent_task_created_before_tracking_started + @task_definition.update_columns( + created_at: 1.day.ago, + start_date: 1.month.ago, + target_date: 3.weeks.ago, + new_task_notifications_from: Time.current + ) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_accepts_a_rolling_writer_marker_just_after_creation + @task_definition.update_columns( + created_at: 2.days.ago, + start_date: 3.days.ago, + target_date: 1.week.from_now, + new_task_notifications_from: 2.days.ago + 10.seconds + ) + + assert_difference 'Notification.count', 1 do + run_job + end + end + + def test_ignores_inactive_units_and_withdrawn_projects + travel_to @release_date.noon do + @unit.update!(active: false) + + assert_no_difference 'Notification.count' do + run_job + end + + @unit.update!(active: true) + @project.update!(enrolled: false) + + assert_no_difference 'Notification.count' do + run_job + end + end + end + + def test_catches_up_after_an_inactive_unit_is_reactivated + @unit.update!(active: false) + + travel_to @release_date.noon do + assert_no_difference 'Notification.count' do + run_job + end + end + + @unit.update!(active: true) + travel_to (@release_date + 1.day).noon do + assert_difference 'Notification.count', 1 do + run_job + end + end + end + + def test_ignores_definitions_not_created_by_a_supported_workflow + unsupported = FactoryBot.create( + :task_definition, + unit: @unit, + outcome_count: 0, + target_grade: 1, + start_date: @release_date, + target_date: @release_date + 1.week + ) + assert_nil unsupported.reload.new_task_notifications_from + @task_definition.update_column(:new_task_notifications_from, nil) + + travel_to @release_date.noon do + assert_no_difference 'Notification.count' do + run_job + end + end + end + + def test_rollover_notifies_students_enrolled_before_the_copied_task_releases + rolled_unit = @unit.rollover( + nil, + Time.zone.today + 4.weeks, + Time.zone.today + 16.weeks, + "ROLLED-#{SecureRandom.hex(3)}" + ) + rolled_task = rolled_unit.task_definitions.find_by!( + abbreviation: @task_definition.abbreviation + ) + rolled_project = FactoryBot.create( + :project, + unit: rolled_unit, + campus: Campus.first, + user: @student, + enrolled: true, + target_grade: @project.target_grade + ) + @unit.update!(active: false) + + travel_to rolled_task.start_date.to_date.noon do + assert_difference 'Notification.count', 1 do + run_job + end + + notification = Notification.find_by!( + user: @student, + event: NewTaskAvailableNotificationJob::EVENT + ) + assert_equal( + "/projects/#{rolled_project.id}/dashboard/#{rolled_task.abbreviation}", + notification.link + ) + end + ensure + rolled_unit&.destroy + end + + private + + def run_job + SendNewTaskAvailableNotificationsJob.new.perform + end +end diff --git a/test/sidekiq/task_due_date_changed_notification_job_test.rb b/test/sidekiq/task_due_date_changed_notification_job_test.rb new file mode 100644 index 0000000000..95c0730b67 --- /dev/null +++ b/test/sidekiq/task_due_date_changed_notification_job_test.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TaskDueDateChangedNotificationJobTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + EVENT = 'task_due_date_changed' + + setup do + @unit = FactoryBot.create(:unit, task_count: 0) + @task_def = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: 1 + ) + + @previous_due_date = @task_def[:due_date]&.to_date&.iso8601 + changed_due_date = (@task_def.due_date + 1.week).to_date + + @task_def.update!(due_date: changed_due_date) + @new_due_date = changed_due_date.iso8601 + + ActionMailer::Base.deliveries.clear + end + + def test_notifies_every_eligible_student_without_creating_tasks + expected = eligible_projects.count + + assert_operator expected, :>=, 2 + assert_equal 0, @task_def.tasks.count + + assert_difference 'Notification.count', expected do + assert_no_difference 'Task.count' do + run_job + end + end + + assert_equal expected, ActionMailer::Base.deliveries.count + end + + def test_does_not_notify_student_below_target_grade + project = @unit.active_projects.find_by!(target_grade: 0) + + run_job + + assert_not Notification.exists?( + user: project.student, + event: EVENT + ) + end + + def test_does_not_notify_withdrawn_student + project = @unit.projects.find_by!(enrolled: false) + project.update!(target_grade: 3) + + run_job + + assert_not Notification.exists?( + user: project.student, + event: EVENT + ) + end + + def test_respects_task_notification_preference + project = eligible_projects.first + project.student.update!(receive_task_notifications: false) + + run_job + + assert_not Notification.exists?( + user: project.student, + event: EVENT + ) + end + + def test_does_not_notify_for_inactive_unit + @unit.update!(active: false) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_skips_stale_job_after_another_due_date_change + @task_def.update!(due_date: @task_def.due_date + 1.week) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_direct_model_change_does_not_enqueue_job + task_definition = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: 1 + ) + + assert_no_difference( + -> { TaskDueDateChangedNotificationJob.jobs.size } + ) do + task_definition.update!( + due_date: task_definition.due_date + 1.day + ) + end + end + + def test_message_and_link_are_privacy_safe + project = eligible_projects.first + + run_job + + notification = Notification.find_by!( + user: project.student, + event: EVENT + ) + + assert_includes notification.message, @task_def.abbreviation + assert_includes notification.message, @unit.code + assert_not_includes notification.message, @new_due_date + + assert_equal( + "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}", + notification.link + ) + push = assert_valid_push_payload( + notification, + expected_link: "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}" + ) + assert_not_includes push['body'], @new_due_date + end + + def test_event_specific_template_is_used + run_job + + assert_includes( + delivered_body, + 'The new due date is not included in this email' + ) + end + + private + + def run_job + TaskDueDateChangedNotificationJob.new.perform( + @task_def.id, + @previous_due_date, + @new_due_date + ) + NotificationEmailJob.drain + end + + def eligible_projects + @unit.active_projects.where( + 'projects.target_grade >= ?', + @task_def.target_grade + ) + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + return mail.body.decoded unless mail.multipart? + + mail.parts.map { |part| part.body.decoded }.join("\n") + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c3aae2fe2b..29964de212 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" @@ -32,13 +34,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 +62,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! @@ -84,7 +82,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