diff --git a/enterprise-lms-roster-passback-guard/README.md b/enterprise-lms-roster-passback-guard/README.md new file mode 100644 index 00000000..093ace47 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/README.md @@ -0,0 +1,32 @@ +# Enterprise LMS Roster Passback Guard + +Self-contained Enterprise Tooling slice for issue #19. It validates Canvas/Moodle roster and grade-passback sync evidence before an institution enables LMS integration events. + +## What It Checks + +- Course-section enrollment stays inside approved enterprise sync scope. +- Dropped enrollment state is fresh enough for roster export. +- ORCID/profile linkage exists for enterprise reporting. +- Student roster sync consent is present before FERPA-sensitive data moves. +- Grade passback events occur inside the approved release window. +- Grade and roster events have acknowledged webhook receipts. + +## Outputs + +- `reports/lms-sync-packet.json`: structured reviewer decisions and findings. +- `reports/lms-sync-report.md`: readable report for each synthetic LMS sync scenario. +- `reports/summary.svg`: visual summary of approve, review, hold, and block decisions. +- `reports/demo.mp4`: short demo artifact for Algora review. + +## Local Verification + +```bash +node enterprise-lms-roster-passback-guard/test.js +node enterprise-lms-roster-passback-guard/demo.js +node --check enterprise-lms-roster-passback-guard/index.js +node --check enterprise-lms-roster-passback-guard/test.js +node --check enterprise-lms-roster-passback-guard/demo.js +node --check enterprise-lms-roster-passback-guard/sample-data.js +``` + +The module is dependency-free, uses synthetic data only, and makes no network calls. diff --git a/enterprise-lms-roster-passback-guard/demo.js b/enterprise-lms-roster-passback-guard/demo.js new file mode 100644 index 00000000..9f576ae0 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/demo.js @@ -0,0 +1,59 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const {evaluateLmsSyncGuard, buildLmsSyncReport} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateLmsSyncGuard(scenario), +})); + +const packetJson = JSON.stringify(evaluations, null, 2); +const reviewerReport = evaluations.map(buildLmsSyncReport).join('\n---\n'); +const approved = evaluations.filter((item) => item.decision === 'approve-sync').length; +const review = evaluations.filter((item) => item.decision === 'needs-admin-review').length; +const passback = evaluations.filter((item) => item.decision === 'hold-passback').length; +const blocked = evaluations.filter((item) => item.decision === 'block-sync').length; +const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0); + +const svg = ` + + Enterprise LMS Roster Passback Guard + Synthetic Canvas/Moodle roster and grade sync safety packet + + + Approve + ${approved} + + + + Admin Review + ${review} + + + + Passback Hold + ${passback} + + + + Block + ${blocked} + + Checks: approved sections, FERPA consent, ORCID linkage, release windows, webhook receipts + Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No live LMS calls, student records, credentials, or external providers. + +`; + +fs.writeFileSync(path.join(reportsDir, 'lms-sync-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'lms-sync-report.md'), reviewerReport); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +console.log(`Wrote ${evaluations.length} LMS sync evaluations to ${reportsDir}`); +console.log(`Decision counts: approved=${approved}, review=${review}, passback=${passback}, blocked=${blocked}`); +console.log(`Reviewer findings: ${findings}`); diff --git a/enterprise-lms-roster-passback-guard/index.js b/enterprise-lms-roster-passback-guard/index.js new file mode 100644 index 00000000..bfcef054 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/index.js @@ -0,0 +1,220 @@ +const staleDropDays = 14; + +function normalizeList(value) { + return Array.isArray(value) ? value : []; +} + +function guardAction(type, target, reason) { + return {type, target, reason}; +} + +function daysBetween(start, end) { + return Math.floor((Date.parse(end) - Date.parse(start)) / 86400000); +} + +function isInsideWindow(timestamp, window) { + if (!timestamp || !window) { + return false; + } + return Date.parse(timestamp) >= Date.parse(window.opensAt) && Date.parse(timestamp) <= Date.parse(window.closesAt); +} + +function countByType(findings, type) { + return findings.filter((finding) => finding.type === type).length; +} + +function severityCounts(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] || 0) + 1; + return counts; + }, {}); +} + +function evaluateLmsSyncGuard(input) { + const roster = normalizeList(input.roster); + const passbackEvents = normalizeList(input.passbackEvents); + const webhookReceipts = normalizeList(input.webhookReceipts); + const approvedSections = new Set(normalizeList(input.approvedSections)); + const receiptByEvent = new Map(webhookReceipts.map((receipt) => [receipt.eventId, receipt])); + const findings = []; + const requiredActions = []; + + for (const enrollment of roster) { + if (!approvedSections.has(enrollment.sectionId)) { + findings.push({ + type: 'out-of-scope-section', + severity: 'critical', + userId: enrollment.userId, + sectionId: enrollment.sectionId, + message: `${enrollment.userId} is enrolled in unapproved section ${enrollment.sectionId}`, + }); + requiredActions.push(guardAction( + 'remove_out_of_scope_enrollment', + enrollment.userId, + 'LMS sync must stay inside approved institutional course sections' + )); + } + + if (enrollment.status === 'dropped' && enrollment.lastChangedAt && daysBetween(enrollment.lastChangedAt, input.generatedAt) > staleDropDays) { + findings.push({ + type: 'stale-dropped-enrollment', + severity: 'major', + userId: enrollment.userId, + lastChangedAt: enrollment.lastChangedAt, + message: `${enrollment.userId} has stale dropped enrollment state from ${enrollment.lastChangedAt}`, + }); + requiredActions.push(guardAction( + 'confirm_dropped_enrollment', + enrollment.userId, + 'stale dropped students need admin confirmation before roster sync' + )); + } + + if (!enrollment.orcid) { + findings.push({ + type: 'missing-orcid-linkage', + severity: 'major', + userId: enrollment.userId, + message: `${enrollment.userId} lacks ORCID/profile linkage for enterprise reporting`, + }); + requiredActions.push(guardAction( + 'link_orcid_profile', + enrollment.userId, + 'enterprise reporting needs stable researcher identity linkage' + )); + } + + if (!enrollment.consent) { + findings.push({ + type: 'missing-ferpa-consent', + severity: 'critical', + userId: enrollment.userId, + message: `${enrollment.userId} lacks FERPA-safe sync consent`, + }); + requiredActions.push(guardAction( + 'confirm_lms_sync_consent', + enrollment.userId, + 'student roster data cannot sync without consent evidence' + )); + } + } + + for (const event of passbackEvents) { + if (!approvedSections.has(event.sectionId)) { + findings.push({ + type: 'passback-section-out-of-scope', + severity: 'critical', + eventId: event.eventId, + sectionId: event.sectionId, + message: `${event.eventId} targets unapproved section ${event.sectionId}`, + }); + requiredActions.push(guardAction( + 'block_passback_event', + event.eventId, + 'grade passback section must be approved for this institution' + )); + } + + if (!isInsideWindow(event.releasedAt, input.gradeReleaseWindow)) { + findings.push({ + type: 'grade-release-window-violation', + severity: 'major', + eventId: event.eventId, + releasedAt: event.releasedAt, + message: `${event.eventId} released outside configured grade window`, + }); + requiredActions.push(guardAction( + 'hold_grade_passback', + event.eventId, + 'grade releases must occur inside the approved LMS release window' + )); + } + + const receipt = receiptByEvent.get(event.eventId); + if (!receipt || !receipt.acknowledged) { + findings.push({ + type: 'missing-webhook-acknowledgement', + severity: 'major', + eventId: event.eventId, + message: `${event.eventId} lacks acknowledged LMS webhook receipt`, + }); + requiredActions.push(guardAction( + 'replay_or_confirm_webhook', + event.eventId, + 'grade passback must have an acknowledged LMS webhook receipt' + )); + } + } + + const counts = severityCounts(findings); + const criticalCount = counts.critical || 0; + const majorCount = counts.major || 0; + const decision = criticalCount > 0 + ? 'block-sync' + : findings.some((finding) => finding.type.includes('passback') || finding.type.includes('grade') || finding.type.includes('webhook')) + ? 'hold-passback' + : majorCount > 0 + ? 'needs-admin-review' + : 'approve-sync'; + const syncReadinessScore = Math.max(0, 100 - criticalCount * 40 - majorCount * 20); + + return { + syncId: input.syncId, + generatedAt: input.generatedAt, + institution: input.institution, + decision, + syncReadinessScore, + findings, + requiredActions, + summary: { + rosterCount: roster.length, + passbackEventCount: passbackEvents.length, + webhookReceiptCount: webhookReceipts.length, + approvedSectionCount: approvedSections.size, + outOfScopeEnrollments: countByType(findings, 'out-of-scope-section'), + staleDrops: countByType(findings, 'stale-dropped-enrollment'), + missingOrcidLinks: countByType(findings, 'missing-orcid-linkage'), + passbackHolds: findings.filter((finding) => finding.type.includes('passback') || finding.type.includes('grade') || finding.type.includes('webhook')).length, + severityCounts: counts, + }, + }; +} + +function buildLmsSyncReport(result) { + const lines = [ + '# Enterprise LMS Roster Passback Guard Report', + '', + `Sync: ${result.syncId}`, + `Institution: ${result.institution}`, + `Generated: ${result.generatedAt}`, + `Decision: ${result.decision}`, + `Sync readiness score: ${result.syncReadinessScore}`, + '', + '## Summary', + '', + `Roster entries: ${result.summary.rosterCount}`, + `Passback events: ${result.summary.passbackEventCount}`, + `Webhook receipts: ${result.summary.webhookReceiptCount}`, + `Approved sections: ${result.summary.approvedSectionCount}`, + `Findings: ${result.findings.length}`, + '', + '## Findings', + '', + ...(result.findings.length + ? result.findings.map((finding) => `- ${finding.severity}: ${finding.type} - ${finding.message}`) + : ['- None']), + '', + '## Required Actions', + '', + ...(result.requiredActions.length + ? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`) + : ['- None']), + '', + ]; + return lines.join('\n'); +} + +module.exports = { + evaluateLmsSyncGuard, + buildLmsSyncReport, +}; diff --git a/enterprise-lms-roster-passback-guard/reports/demo.mp4 b/enterprise-lms-roster-passback-guard/reports/demo.mp4 new file mode 100644 index 00000000..0cf3b416 Binary files /dev/null and b/enterprise-lms-roster-passback-guard/reports/demo.mp4 differ diff --git a/enterprise-lms-roster-passback-guard/reports/lms-sync-packet.json b/enterprise-lms-roster-passback-guard/reports/lms-sync-packet.json new file mode 100644 index 00000000..5ed38f13 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/reports/lms-sync-packet.json @@ -0,0 +1,156 @@ +[ + { + "scenario": "out-of-scope-section-block", + "syncId": "sync-canvas-biology", + "generatedAt": "2026-05-22T14:40:00Z", + "institution": "North Valley University", + "decision": "block-sync", + "syncReadinessScore": 60, + "findings": [ + { + "type": "out-of-scope-section", + "severity": "critical", + "userId": "u-ben", + "sectionId": "CHEM-999-Z", + "message": "u-ben is enrolled in unapproved section CHEM-999-Z" + } + ], + "requiredActions": [ + { + "type": "remove_out_of_scope_enrollment", + "target": "u-ben", + "reason": "LMS sync must stay inside approved institutional course sections" + } + ], + "summary": { + "rosterCount": 2, + "passbackEventCount": 0, + "webhookReceiptCount": 1, + "approvedSectionCount": 2, + "outOfScopeEnrollments": 1, + "staleDrops": 0, + "missingOrcidLinks": 0, + "passbackHolds": 0, + "severityCounts": { + "critical": 1 + } + } + }, + { + "scenario": "admin-review-roster-drift", + "syncId": "sync-moodle-physics", + "generatedAt": "2026-05-22T14:40:00Z", + "institution": "North Valley University", + "decision": "needs-admin-review", + "syncReadinessScore": 60, + "findings": [ + { + "type": "stale-dropped-enrollment", + "severity": "major", + "userId": "u-clio", + "lastChangedAt": "2026-05-01T00:00:00Z", + "message": "u-clio has stale dropped enrollment state from 2026-05-01T00:00:00Z" + }, + { + "type": "missing-orcid-linkage", + "severity": "major", + "userId": "u-drew", + "message": "u-drew lacks ORCID/profile linkage for enterprise reporting" + } + ], + "requiredActions": [ + { + "type": "confirm_dropped_enrollment", + "target": "u-clio", + "reason": "stale dropped students need admin confirmation before roster sync" + }, + { + "type": "link_orcid_profile", + "target": "u-drew", + "reason": "enterprise reporting needs stable researcher identity linkage" + } + ], + "summary": { + "rosterCount": 2, + "passbackEventCount": 0, + "webhookReceiptCount": 1, + "approvedSectionCount": 1, + "outOfScopeEnrollments": 0, + "staleDrops": 1, + "missingOrcidLinks": 1, + "passbackHolds": 0, + "severityCounts": { + "major": 2 + } + } + }, + { + "scenario": "passback-hold", + "syncId": "sync-grade-passback", + "generatedAt": "2026-05-22T14:40:00Z", + "institution": "North Valley University", + "decision": "hold-passback", + "syncReadinessScore": 60, + "findings": [ + { + "type": "grade-release-window-violation", + "severity": "major", + "eventId": "grade-1", + "releasedAt": "2026-05-22T14:00:00Z", + "message": "grade-1 released outside configured grade window" + }, + { + "type": "missing-webhook-acknowledgement", + "severity": "major", + "eventId": "grade-2", + "message": "grade-2 lacks acknowledged LMS webhook receipt" + } + ], + "requiredActions": [ + { + "type": "hold_grade_passback", + "target": "grade-1", + "reason": "grade releases must occur inside the approved LMS release window" + }, + { + "type": "replay_or_confirm_webhook", + "target": "grade-2", + "reason": "grade passback must have an acknowledged LMS webhook receipt" + } + ], + "summary": { + "rosterCount": 1, + "passbackEventCount": 2, + "webhookReceiptCount": 1, + "approvedSectionCount": 1, + "outOfScopeEnrollments": 0, + "staleDrops": 0, + "missingOrcidLinks": 0, + "passbackHolds": 2, + "severityCounts": { + "major": 2 + } + } + }, + { + "scenario": "clean-lms-sync", + "syncId": "sync-clean-lms", + "generatedAt": "2026-05-22T14:40:00Z", + "institution": "North Valley University", + "decision": "approve-sync", + "syncReadinessScore": 100, + "findings": [], + "requiredActions": [], + "summary": { + "rosterCount": 2, + "passbackEventCount": 1, + "webhookReceiptCount": 2, + "approvedSectionCount": 1, + "outOfScopeEnrollments": 0, + "staleDrops": 0, + "missingOrcidLinks": 0, + "passbackHolds": 0, + "severityCounts": {} + } + } +] diff --git a/enterprise-lms-roster-passback-guard/reports/lms-sync-report.md b/enterprise-lms-roster-passback-guard/reports/lms-sync-report.md new file mode 100644 index 00000000..e8ed45fc --- /dev/null +++ b/enterprise-lms-roster-passback-guard/reports/lms-sync-report.md @@ -0,0 +1,102 @@ +# Enterprise LMS Roster Passback Guard Report + +Sync: sync-canvas-biology +Institution: North Valley University +Generated: 2026-05-22T14:40:00Z +Decision: block-sync +Sync readiness score: 60 + +## Summary + +Roster entries: 2 +Passback events: 0 +Webhook receipts: 1 +Approved sections: 2 +Findings: 1 + +## Findings + +- critical: out-of-scope-section - u-ben is enrolled in unapproved section CHEM-999-Z + +## Required Actions + +- remove_out_of_scope_enrollment: u-ben (LMS sync must stay inside approved institutional course sections) + +--- +# Enterprise LMS Roster Passback Guard Report + +Sync: sync-moodle-physics +Institution: North Valley University +Generated: 2026-05-22T14:40:00Z +Decision: needs-admin-review +Sync readiness score: 60 + +## Summary + +Roster entries: 2 +Passback events: 0 +Webhook receipts: 1 +Approved sections: 1 +Findings: 2 + +## Findings + +- major: stale-dropped-enrollment - u-clio has stale dropped enrollment state from 2026-05-01T00:00:00Z +- major: missing-orcid-linkage - u-drew lacks ORCID/profile linkage for enterprise reporting + +## Required Actions + +- confirm_dropped_enrollment: u-clio (stale dropped students need admin confirmation before roster sync) +- link_orcid_profile: u-drew (enterprise reporting needs stable researcher identity linkage) + +--- +# Enterprise LMS Roster Passback Guard Report + +Sync: sync-grade-passback +Institution: North Valley University +Generated: 2026-05-22T14:40:00Z +Decision: hold-passback +Sync readiness score: 60 + +## Summary + +Roster entries: 1 +Passback events: 2 +Webhook receipts: 1 +Approved sections: 1 +Findings: 2 + +## Findings + +- major: grade-release-window-violation - grade-1 released outside configured grade window +- major: missing-webhook-acknowledgement - grade-2 lacks acknowledged LMS webhook receipt + +## Required Actions + +- hold_grade_passback: grade-1 (grade releases must occur inside the approved LMS release window) +- replay_or_confirm_webhook: grade-2 (grade passback must have an acknowledged LMS webhook receipt) + +--- +# Enterprise LMS Roster Passback Guard Report + +Sync: sync-clean-lms +Institution: North Valley University +Generated: 2026-05-22T14:40:00Z +Decision: approve-sync +Sync readiness score: 100 + +## Summary + +Roster entries: 2 +Passback events: 1 +Webhook receipts: 2 +Approved sections: 1 +Findings: 0 + +## Findings + +- None + +## Required Actions + +- None diff --git a/enterprise-lms-roster-passback-guard/reports/summary.svg b/enterprise-lms-roster-passback-guard/reports/summary.svg new file mode 100644 index 00000000..0dcafad6 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/reports/summary.svg @@ -0,0 +1,28 @@ + + + Enterprise LMS Roster Passback Guard + Synthetic Canvas/Moodle roster and grade sync safety packet + + + Approve + 1 + + + + Admin Review + 1 + + + + Passback Hold + 1 + + + + Block + 1 + + Checks: approved sections, FERPA consent, ORCID linkage, release windows, webhook receipts + Reviewer findings: 5. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No live LMS calls, student records, credentials, or external providers. + diff --git a/enterprise-lms-roster-passback-guard/sample-data.js b/enterprise-lms-roster-passback-guard/sample-data.js new file mode 100644 index 00000000..bada2b5a --- /dev/null +++ b/enterprise-lms-roster-passback-guard/sample-data.js @@ -0,0 +1,67 @@ +const scenarios = [ + { + name: 'out-of-scope-section-block', + syncId: 'sync-canvas-biology', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A', 'BIO-501-B'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-ada', role: 'student', sectionId: 'BIO-501-A', status: 'active', orcid: '0000-0001', consent: true}, + {userId: 'u-ben', role: 'student', sectionId: 'CHEM-999-Z', status: 'active', orcid: '0000-0002', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-1', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }, + { + name: 'admin-review-roster-drift', + syncId: 'sync-moodle-physics', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['PHYS-610-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-clio', role: 'student', sectionId: 'PHYS-610-A', status: 'dropped', lastChangedAt: '2026-05-01T00:00:00Z', orcid: '0000-0003', consent: true}, + {userId: 'u-drew', role: 'instructor', sectionId: 'PHYS-610-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-2', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }, + { + name: 'passback-hold', + syncId: 'sync-grade-passback', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['CS-720-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-21T00:00:00Z'}, + roster: [ + {userId: 'u-erin', role: 'student', sectionId: 'CS-720-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0004', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-1', userId: 'u-erin', sectionId: 'CS-720-A', score: 94, releasedAt: '2026-05-22T14:00:00Z'}, + {eventId: 'grade-2', userId: 'u-erin', sectionId: 'CS-720-A', score: 88, releasedAt: '2026-05-20T14:00:00Z'}, + ], + webhookReceipts: [{eventId: 'grade-1', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}], + }, + { + name: 'clean-lms-sync', + syncId: 'sync-clean-lms', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-fox', role: 'student', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0005', consent: true}, + {userId: 'u-gia', role: 'instructor', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0006', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-clean', userId: 'u-fox', sectionId: 'BIO-501-A', score: 91, releasedAt: '2026-05-22T14:00:00Z'}, + ], + webhookReceipts: [ + {eventId: 'grade-clean', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}, + {eventId: 'roster-clean', acknowledged: true, receivedAt: '2026-05-22T14:02:00Z'}, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/enterprise-lms-roster-passback-guard/test.js b/enterprise-lms-roster-passback-guard/test.js new file mode 100644 index 00000000..7fce0f12 --- /dev/null +++ b/enterprise-lms-roster-passback-guard/test.js @@ -0,0 +1,106 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateLmsSyncGuard, + buildLmsSyncReport, +} = require('./index'); + +test('blocks LMS sync when enrollment section is outside approved enterprise scope', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-canvas-biology', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A', 'BIO-501-B'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-ada', role: 'student', sectionId: 'BIO-501-A', status: 'active', orcid: '0000-0001', consent: true}, + {userId: 'u-ben', role: 'student', sectionId: 'CHEM-999-Z', status: 'active', orcid: '0000-0002', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-1', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }); + + assert.equal(result.decision, 'block-sync'); + assert.equal(result.findings[0].type, 'out-of-scope-section'); + assert.equal(result.findings[0].userId, 'u-ben'); + assert.equal(result.summary.outOfScopeEnrollments, 1); +}); + +test('requires admin review for stale enrollment drops and missing ORCID linkage', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-moodle-physics', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['PHYS-610-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-clio', role: 'student', sectionId: 'PHYS-610-A', status: 'dropped', lastChangedAt: '2026-05-01T00:00:00Z', orcid: '0000-0003', consent: true}, + {userId: 'u-drew', role: 'instructor', sectionId: 'PHYS-610-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '', consent: true}, + ], + passbackEvents: [], + webhookReceipts: [{eventId: 'roster-2', acknowledged: true, receivedAt: '2026-05-22T14:41:00Z'}], + }); + + assert.equal(result.decision, 'needs-admin-review'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['stale-dropped-enrollment', 'missing-orcid-linkage'] + ); + assert.equal(result.requiredActions.length, 2); +}); + +test('holds grade passback outside release window or without webhook acknowledgement', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-grade-passback', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['CS-720-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-21T00:00:00Z'}, + roster: [ + {userId: 'u-erin', role: 'student', sectionId: 'CS-720-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0004', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-1', userId: 'u-erin', sectionId: 'CS-720-A', score: 94, releasedAt: '2026-05-22T14:00:00Z'}, + {eventId: 'grade-2', userId: 'u-erin', sectionId: 'CS-720-A', score: 88, releasedAt: '2026-05-20T14:00:00Z'}, + ], + webhookReceipts: [{eventId: 'grade-1', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}], + }); + + assert.equal(result.decision, 'hold-passback'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + ['grade-release-window-violation', 'missing-webhook-acknowledgement'] + ); +}); + +test('approves clean LMS roster and grade passback packet with deterministic report', () => { + const result = evaluateLmsSyncGuard({ + syncId: 'sync-clean-lms', + generatedAt: '2026-05-22T14:40:00Z', + institution: 'North Valley University', + approvedSections: ['BIO-501-A'], + gradeReleaseWindow: {opensAt: '2026-05-20T00:00:00Z', closesAt: '2026-05-30T00:00:00Z'}, + roster: [ + {userId: 'u-fox', role: 'student', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0005', consent: true}, + {userId: 'u-gia', role: 'instructor', sectionId: 'BIO-501-A', status: 'active', lastChangedAt: '2026-05-22T00:00:00Z', orcid: '0000-0006', consent: true}, + ], + passbackEvents: [ + {eventId: 'grade-clean', userId: 'u-fox', sectionId: 'BIO-501-A', score: 91, releasedAt: '2026-05-22T14:00:00Z'}, + ], + webhookReceipts: [ + {eventId: 'grade-clean', acknowledged: true, receivedAt: '2026-05-22T14:01:00Z'}, + {eventId: 'roster-clean', acknowledged: true, receivedAt: '2026-05-22T14:02:00Z'}, + ], + }); + + assert.equal(result.decision, 'approve-sync'); + assert.equal(result.findings.length, 0); + assert.equal(result.syncReadinessScore, 100); + + const report = buildLmsSyncReport(result); + assert.match(report, /# Enterprise LMS Roster Passback Guard Report/); + assert.match(report, /Sync: sync-clean-lms/); + assert.match(report, /Decision: approve-sync/); + assert.match(report, /Findings: 0/); +});