diff --git a/.github/workflows/deploy-to-prod.yaml b/.github/workflows/deploy-to-prod.yaml index d4a0132a..4b60b526 100644 --- a/.github/workflows/deploy-to-prod.yaml +++ b/.github/workflows/deploy-to-prod.yaml @@ -1,9 +1,9 @@ # ============================================================================= # Deploy to Production # ============================================================================= -# Manually triggered workflow to deploy a tested version to production. +# Manually triggered workflow to deploy a version tag to production. # Requires semantic version tag (v1.2.3). -# Validates that version was deployed to test first. +# Checks that the git tag and its image tag exist; it does not check that the version ran on test. # ============================================================================= name: Deploy to Prod diff --git a/TODO.md b/TODO.md index 09334dc0..0250bc0f 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,6 @@ ## Updates - 2026-09-23: Add DB tests that drive POST/PUT with the bodies eagle-admin actually sends. -- 2026-09-24: `documentAggregator.js:97` `signedIn` repeats the isAuthenticated test at `search.js:227`; pass the flag in. - 2026-09-24: GET `/public/document/{id}` and `/fetch` serve an UPDATE image of a scheduled Update before it goes live (public at save); check the Update is live, or accept. ## Sorting @@ -13,14 +12,8 @@ ## Legislation registry: deferred review findings - 2026-10-01 api/controllers/project.js:634: a PUT with no `legislationYear` returns 404 when the stored `currentLegislationYear` is missing or not an exact key (`'legislation_2018 '`, old untrimmed publish). Count such rows on test; if any exist, read the stored year suffix with `Number()`. -- 2026-10-01 api/controllers/project.js:619: no test for a PUT that moves a 1996, 2002 or 2018 project to 2025 (allowed, and one-way after that). -- 2026-10-01 api/dao/projectDAO.js:53: `publishProject` sets `currentLegislationYear` with no lock and no content check; its only caller is its own test. Delete it, or send it through the controller checks. - 2026-10-01 api/controllers/project.js:741: the 409 for a project that moved under a locked Act before the write names every locked Act joined with "or" (PUT and publish). Read the stored key back and name only that Act; test/controllers/project-locked-act.test.js:161 asserts the joined wording. -- 2026-10-01 api/controllers/project.js:775: a non-numeric year on a locked project gets 404 from publish but 409 from PUT. -- 2026-10-01 api/swagger/swagger.yaml:1348: the PUT "404" text should say a locked project gets 409 first. -- 2026-10-01 api/swagger/swagger.yaml:1438: the publish "404" response has no `$ref: "#/definitions/Error"`. - 2026-10-01 api/helpers/demiPush.js:100: `enrichProject` adds proponent and regulation keys to the default-filled 2025 block of 1996, 2002 and 2018 projects (push payload only). -- 2026-10-01 test/controllers/analytics-callsites.test.js:80, test/controllers/demi-push-callsites.test.js:100: only fixtures changed here; check these tests still fail when the code they cover breaks. - 2026-10-01 api/helpers/models/project.js:103: every loaded project gains a default `legislation_2025` object, shown in PUT, publish and unpublish responses and the push body, stored on the next `save()`; the `handleProjectTerms` OR at api/helpers/aggregators.js:308 can match it. - 2026-10-01 api/helpers/models/project.js:103: each Act adds 5 indexes to the shared `epic` collection (limit 64). Check the live index count before a production deploy. - 2026-10-01 api/aggregators/projectAggregator.js:16: `projectLegislation=all` runs extra lookups per row. diff --git a/api/aggregators/documentAggregator.js b/api/aggregators/documentAggregator.js index ecb21cf0..741c9c6b 100644 --- a/api/aggregators/documentAggregator.js +++ b/api/aggregators/documentAggregator.js @@ -15,11 +15,13 @@ const { IMAGE_SOURCE } = require('../helpers/updateRules'); * @param {boolean} caseSensitive Case sensitive search? * @param {array} orModifier Search criteria for an 'or' search * @param {array} andModifier Search criteria for an 'and' search + * @param {boolean} categorized true: categorized only, false: uncategorized only, unset: all * @param {array} roles User's roles + * @param {boolean} signedIn Caller is signed in (search.js isAuthenticated) * * @returns {array} Aggregation for a document match */ -exports.createMatchAggr = async (schemaName, projectId, keywords, caseSensitive, orModifier, andModifier, categorized, roles) => { +exports.createMatchAggr = async (schemaName, projectId, keywords, caseSensitive, orModifier, andModifier, categorized, roles, signedIn = false) => { const aggregation = []; let projectModifier; let keywordModifier; @@ -94,7 +96,6 @@ exports.createMatchAggr = async (schemaName, projectId, keywords, caseSensitive, // Update form images belong to their Update, not the document lists, unless a signed-in caller asks // for a source. A scheduled Update's images are public before it is live, so the public never gets them here. - const signedIn = Array.isArray(roles) && roles.some(role => role !== 'public'); const asksForSource = [andModifier, orModifier].some(m => Object.keys(qs.parse(m)).includes('documentSource')); const sourceModifier = asksForSource && signedIn ? undefined diff --git a/api/controllers/comment.js b/api/controllers/comment.js index c065b988..7dadd238 100644 --- a/api/controllers/comment.js +++ b/api/controllers/comment.js @@ -4,6 +4,7 @@ var Actions = require('../helpers/actions'); var Utils = require('../helpers/utils'); const demiPush = require('../helpers/demiPush'); const { DateTime } = require('luxon'); +const { COMMENT_STAFF_FIELDS } = require('../helpers/constants'); const ALLOWED_FIELDS = [ 'author', @@ -29,6 +30,24 @@ const ALLOWED_FIELDS = [ 'write', 'delete' ]; +// Staff review fields stay off the public routes. +const PUBLIC_FIELDS = ALLOWED_FIELDS.filter(f => !COMMENT_STAFF_FIELDS.includes(f)); + +// Hiding an anonymous author needs isAnonymous: read it with author, drop it when the caller did not ask. +const publicCommentQuery = fields => { + const readsAnonymity = fields.includes('author') && !fields.includes('isAnonymous'); + return { + fields: readsAnonymity ? [...fields, 'isAnonymous'] : fields, + redact: item => { + if (item.isAnonymous !== false) { + delete item.author; + } + if (readsAnonymity) { + delete item.isAnonymous; + } + } + }; +}; var setPermissionsFromEaoStatus = function (status, comment) { defaultLog.debug('setPermissionsFromEaoStatus: %s', status); @@ -78,7 +97,7 @@ exports.publicHead = async function (args, res) { query = Utils.buildQuery('period', args.swagger.params.period.value, query); } - const fields = Utils.sanitizeFields(args.swagger.params.fields.value, ALLOWED_FIELDS); + const fields = Utils.sanitizeFields(args.swagger.params.fields.value, PUBLIC_FIELDS); // Set query type Object.assign(query, { '_schemaName': 'Comment' }); @@ -103,10 +122,13 @@ exports.publicHead = async function (args, res) { exports.publicGet = async function (args, res) { var query = {}, sort = {}; var skip = null, limit = null; + const commentId = args.swagger.params.commentId && args.swagger.params.commentId.value; + // The by-id route declares no `count` parameter. + const wantsCount = Boolean(args.swagger.params.count && args.swagger.params.count.value); // Build match query if on commentId route. - if (args.swagger.params.commentId && args.swagger.params.commentId.value) { - query = Utils.buildQuery('_id', args.swagger.params.commentId.value, query); + if (commentId) { + query = Utils.buildQuery('_id', commentId, query); } else { if (args.swagger.params.period && args.swagger.params.period.value) { query = Utils.buildQuery('period', args.swagger.params.period.value, query); @@ -125,34 +147,42 @@ exports.publicGet = async function (args, res) { limit = processedParameters.limit; } - const fields = Utils.sanitizeFields(args.swagger.params.fields.value, ALLOWED_FIELDS); + const commentQuery = publicCommentQuery(Utils.sanitizeFields(args.swagger.params.fields.value, PUBLIC_FIELDS)); // Set query type Object.assign(query, { '_schemaName': 'Comment' }); var data = await Utils.runDataQuery('Comment', ['public'], query, - fields, // Fields + commentQuery.fields, // Fields null, sort, // sort skip, // skip limit, // limit true); // count + const results = data[0] ? data[0].results : []; + results.forEach(commentQuery.redact); + + // An array, empty when not found: eagle-public tags v2.7.36 and older and the DEMI parity check both read it so. + if (commentId) { + if (results.length === 0) { + defaultLog.info('publicGet: comment %s not found', commentId); + } else { + Utils.recordAction('Get', 'Comment', 'public', commentId); + } + return Actions.sendResponse(res, 200, results); + } + if (data[0] == null) { - if (args.swagger.params.count.value) { + if (wantsCount) { res.setHeader('x-total-count', 0); } return Actions.sendResponse(res, 200, data); } - data[0].results.forEach(function (item) { - if (item.isAnonymous === true) { - delete item.author; - } - }); - if (args.swagger.params.count.value) { - Utils.recordAction('Get', 'Comment', 'public', args.swagger.params.commentId && args.swagger.params.commentId.value ? args.swagger.params.commentId.value : null); + if (wantsCount) { + Utils.recordAction('Get', 'Comment', 'public', null); res.setHeader('x-total-count', data && data.length > 0 ? data[0].total_items : 0); return Actions.sendResponse(res, 200, data.length !== 0 ? data[0].results : []); } else { diff --git a/api/controllers/project.js b/api/controllers/project.js index 465cbc21..cf69f499 100644 --- a/api/controllers/project.js +++ b/api/controllers/project.js @@ -776,7 +776,7 @@ exports.protectedPublish = async function (args, res) { if (ProjObject && ProjObject.legislationYear) { const year = Number(ProjObject.legislationYear); - if (refusesLegislationChange(o, year)) { + if (refusesLegislationChange(o, ProjObject.legislationYear)) { const current = [o.currentLegislationYear]; defaultLog.warn('protectedPublish refused: project %s is under %s, request named %s', objId, actLabels(current), ProjObject.legislationYear); return Actions.sendResponse(res, 409, lockedActRefusal(current)); diff --git a/api/controllers/search.js b/api/controllers/search.js index ed83b84f..1f83a59d 100644 --- a/api/controllers/search.js +++ b/api/controllers/search.js @@ -6,6 +6,7 @@ const Actions = require('../helpers/actions'); const Utils = require('../helpers/utils'); const analytics = require('../helpers/analytics'); const constants = require('../helpers/constants').schemaTypes; +const { COMMENT_STAFF_FIELDS } = require('../helpers/constants'); const documentAggregator = require('../aggregators/documentAggregator'); const projectAggregator = require('../aggregators/projectAggregator'); @@ -33,7 +34,7 @@ const PROJECT_JOINED_FIELDS = ['proponent', 'currentPhaseName', 'eacDecision', ' // Project fields still on the root before the page is cut; unwindProjectData copies them into default after it. const PROJECT_ROOT_SORT_FIELDS = ['_id', ...aggregateHelper.PROJECT_ROOT_FIELDS]; -const searchCollection = async function (roles, keywords, schemaName, pageNum, pageSize, project, projectLegislation, sortField = undefined, sortDirection = undefined, caseSensitive, populate = false, and, or, sortingValue, categorized, fuzzy) { +const searchCollection = async function (roles, keywords, schemaName, pageNum, pageSize, project, projectLegislation, sortField = undefined, sortDirection = undefined, caseSensitive, populate = false, and, or, sortingValue, categorized, fuzzy, signedIn) { defaultLog.debug('collation:', aggregateHelper.AGGREGATE_COLLATION); defaultLog.debug('populate:', populate); @@ -49,7 +50,7 @@ const searchCollection = async function (roles, keywords, schemaName, pageNum, p let parentGateAggregation = []; switch (schemaName) { case constants.DOCUMENT: { - matchAggregation = await documentAggregator.createMatchAggr(schemaName, project, decodedKeywords, caseSensitive, or, and, categorized, roles, fuzzy); + matchAggregation = await documentAggregator.createMatchAggr(schemaName, project, decodedKeywords, caseSensitive, or, and, categorized, roles, signedIn); const unreadableParentIds = await parentRead.unreadableParentIds(roles); schemaAggregation = await documentAggregator.createDocumentAggr(populate, roles, sortingValue, sortField, sortDirection, pageNum, pageSize, unreadableParentIds); break; @@ -342,7 +343,7 @@ const executeQuery = async function (args, res) { } if (dataset !== constants.ITEM) { - const collectionData = await searchCollection(roles, keywords, dataset, pageNum, pageSize, project, projectLegislation, sortField, sortDirection, caseSensitive, populate, and, or, sortingValue, categorized, fuzzy); + const collectionData = await searchCollection(roles, keywords, dataset, pageNum, pageSize, project, projectLegislation, sortField, sortDirection, caseSensitive, populate, and, or, sortingValue, categorized, fuzzy, isAuthenticated); // Only a keyword search counts: this endpoint also serves plain browse and filter requests, and // those would drown the searches someone actually typed. @@ -358,7 +359,7 @@ const executeQuery = async function (args, res) { if (dataset === constants.COMMENT) { // Filter collectionData[0].searchResults.forEach(function (item) { - if (item.isAnonymous === true) { + if (item.isAnonymous !== false) { delete item.author; } }); @@ -383,9 +384,12 @@ const executeQuery = async function (args, res) { if (schemaNameVal === constants.COMMENT) { // Filter data.forEach(function (item) { - if (item.isAnonymous === true) { + if (item.isAnonymous !== false) { delete item.author; } + if (!isAuthenticated) { + COMMENT_STAFF_FIELDS.forEach(field => delete item[field]); + } }); } diff --git a/api/dao/projectDAO.js b/api/dao/projectDAO.js index 4a19eb57..aca83c12 100644 --- a/api/dao/projectDAO.js +++ b/api/dao/projectDAO.js @@ -2,7 +2,6 @@ const defaultLog = require('winston').loggers.get('default'); const mongoose = require('mongoose'); const Actions = require('../helpers/actions'); const Utils = require('../helpers/utils'); -const { legislationKey } = require('../helpers/constants'); exports.projectHateoas = function(project, roles) { project.links = @@ -50,23 +49,6 @@ exports.deleteProject = async function(user, project) { }); }; -exports.publishProject = async function(user, project) { - const key = project && project.legislationYear ? legislationKey(project.legislationYear) : null; - if (key) { - project.currentLegislationYear = key; - } - - return Actions.publish(project, true) - .then(function (published) { - Utils.recordAction('Publish', 'Project', user, project._id); - - return published; - }) - .catch(function (err) { - throw Error('Failed to publish project', err); - }); -}; - exports.unPublishProject = async function(user, project) { return Actions.unPublish(project, true) .then(function (unpublished) { diff --git a/api/helpers/constants.js b/api/helpers/constants.js index c6a55200..a9dbc3f0 100644 --- a/api/helpers/constants.js +++ b/api/helpers/constants.js @@ -20,6 +20,9 @@ exports.MAX_FEATURE_DOCS = 5; exports.PUBLIC_ROLES = ['public']; exports.SECURE_ROLES = ['sysadmin', 'staff']; +// Comment fields only signed-in staff may read. +exports.COMMENT_STAFF_FIELDS = Object.freeze(['eaoNotes', 'eaoStatus', 'proponentNotes', 'rejectedNotes', 'rejectedReason']); + // Legislation registry, keyed by year. A project keeps one sub-document per year under `legislation_`. // Adding an Act: add its entry here and its year to the project POST and publish prose in swagger.yaml (a test checks). // `label`: the `legislation` string written on the project; eagle-public matches on it. diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 66ceceac..43080af1 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -474,6 +474,25 @@ definitions: - 'read' - 'write' - 'delete' + PublicCommentFields: + type: string + description: "Optional fields to return on the public routes: no staff review fields." + enum: &publicCommentFields + - 'author' + - 'comment' + - 'commentId' + - 'dateAdded' + - 'datePosted' + - 'dateUpdated' + - 'isAnonymous' + - 'location' + - 'period' + - 'proponentStatus' + - 'publishedNotes' + - 'valuedComponents' + - 'read' + - 'write' + - 'delete' CommentSortByFields: type: string description: "Optional fields to sort by (prefixed with + or - for order)" @@ -1306,7 +1325,9 @@ paths: schema: $ref: "#/definitions/Error" "404": - description: "Project not found, or legislationYear is not a known legislation year" + description: "Project not found, or legislationYear is not a known legislation year. A project under a locked Act gets 409 first when legislationYear names any other year" + schema: + $ref: "#/definitions/Error" "409": description: "Project's current Act is locked (today the Building Canada Act, 2025) and legislationYear names another year" schema: @@ -1396,7 +1417,9 @@ paths: schema: $ref: "#/definitions/Error" "404": - description: "Project not found, or legislationYear is not a known legislation year" + description: "Project not found, or legislationYear is not a known legislation year. A project under a locked Act gets 409 first when legislationYear names any other year" + schema: + $ref: "#/definitions/Error" "409": description: "Project's current Act is locked (today the Building Canada Act, 2025) and legislationYear names another year, or legislationYear is not the current year and the project has no content under it" schema: @@ -5310,7 +5333,7 @@ paths: collectionFormat: pipes items: type: string - enum: *commentFields + enum: *publicCommentFields responses: "200": description: "Success" @@ -5351,7 +5374,7 @@ paths: collectionFormat: pipes items: type: string - enum: *commentFields + enum: *publicCommentFields - in: query name: pageNum type: number @@ -5422,12 +5445,14 @@ paths: collectionFormat: pipes items: type: string - enum: *commentFields + enum: *publicCommentFields responses: "200": - description: "Success" + description: "An array holding the one comment, empty when no public comment has this id" schema: - $ref: "#/definitions/CommentObject" + type: array + items: + $ref: "#/definitions/CommentObject" "403": description: "Access Denied" schema: diff --git a/test/aggregators/aggregators.test.js b/test/aggregators/aggregators.test.js index 959d6ded..5afc1915 100644 --- a/test/aggregators/aggregators.test.js +++ b/test/aggregators/aggregators.test.js @@ -79,6 +79,18 @@ describe('Document Aggregator', () => { expect(result[0].$match).to.have.property('milestone'); expect(result[0].$match).to.have.property('documentAuthorType'); }); + + it('keeps Update images when a signed-in caller filters on documentSource, even one with no roles', async () => { + const result = await documentAggregator.createMatchAggr('Document', null, null, false, null, 'documentSource=UPDATE', null, [], true); + + expect(result[0].$match).to.not.have.property('documentSource'); + }); + + it('hides Update images from a caller who is not signed in, even when it filters on documentSource', async () => { + const result = await documentAggregator.createMatchAggr('Document', null, null, false, null, 'documentSource=UPDATE', null, ['public'], false); + + expect(result[0].$match.documentSource).to.deep.equal({ $ne: 'UPDATE' }); + }); }); describe('createDocumentAggr', () => { diff --git a/test/controllers/comment.test.js b/test/controllers/comment.test.js index d3a1a606..e1677fc8 100644 --- a/test/controllers/comment.test.js +++ b/test/controllers/comment.test.js @@ -147,18 +147,115 @@ describe('Comment Controller', () => { await commentController.publicHead(args, res); expect(res.setHeader.calledWith('x-total-count', 12)).to.be.true; }); + + it('never reads the staff review fields for a public caller', async () => { + const args = makeArgs({ fields: { value: ['comment', 'eaoNotes', 'eaoStatus', 'proponentNotes', 'rejectedNotes', 'rejectedReason'] } }); + Utils.runDataQuery.resolves([{ total_items: 1 }]); + + await commentController.publicHead(args, res); + + expect(Utils.runDataQuery.firstCall.args[3]).to.deep.equal(['comment']); + }); }); describe('publicGet', () => { - it('returns public comment query results', async () => { - const args = makeArgs(); - Utils.runDataQuery.resolves([{ - results: [{ _id: VALID_COMM_ID, author: 'Jane', isAnonymous: false }], - total_items: 1 - }]); + const STORED = [ + { _id: VALID_COMM_ID, read: ['public'], author: 'Jane', comment: 'Hi', isAnonymous: true, eaoNotes: 'staff only' }, + { _id: VALID_CP_ID, read: ['public'], author: 'Sam', comment: 'Yo', isAnonymous: false, eaoNotes: 'staff only' } + ]; + // Stored before isAnonymous existed; the model default makes it anonymous. + const OLD_ROW = { _id: VALID_DOC_ID, read: ['public'], author: 'Kim', comment: 'Old' }; + // Answers like runDataQuery with count: a facet, holding only _id, read and the fields asked for. + const answerFrom = rows => Utils.runDataQuery.callsFake(async (model, roles, query, fields) => [{ + total_items: rows.length, + results: rows.map(row => Object.fromEntries(Object.entries(row).filter(([k]) => ['_id', 'read', ...fields].includes(k)))) + }]); + const fieldsAsked = () => Utils.runDataQuery.firstCall.args[3]; + const listArgs = (overrides = {}) => { + const args = makeArgs(overrides); + delete args.swagger.params.commentId; + return args; + }; - await commentController.publicGet(args, res); - expect(res.status.calledWith(200)).to.be.true; + it('lists a period as the count facet when no count is asked, without anonymous authors', async () => { + answerFrom(STORED); + + await commentController.publicGet(listArgs({ fields: { value: ['author', 'comment'] } }), res); + + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal([{ total_items: 2, results: [ + { _id: VALID_COMM_ID, read: ['public'], comment: 'Hi' }, + { _id: VALID_CP_ID, read: ['public'], author: 'Sam', comment: 'Yo' } + ] }]); + }); + + it('lists a period with its total count when asked', async () => { + answerFrom(STORED); + + await commentController.publicGet(listArgs({ count: { value: true }, fields: { value: ['comment', 'isAnonymous'] } }), res); + + expect(res.setHeader.calledWith('x-total-count', 2)).to.be.true; + expect(res.json.firstCall.args[0]).to.deep.equal([ + { _id: VALID_COMM_ID, read: ['public'], comment: 'Hi', isAnonymous: true }, + { _id: VALID_CP_ID, read: ['public'], comment: 'Yo', isAnonymous: false } + ]); + }); + + it('hides anonymous authors in a counted list, and drops isAnonymous when not asked for', async () => { + answerFrom([...STORED, OLD_ROW]); + + await commentController.publicGet(listArgs({ count: { value: true }, fields: { value: ['author', 'comment'] } }), res); + + expect(res.json.firstCall.args[0]).to.deep.equal([ + { _id: VALID_COMM_ID, read: ['public'], comment: 'Hi' }, + { _id: VALID_CP_ID, read: ['public'], author: 'Sam', comment: 'Yo' }, + { _id: VALID_DOC_ID, read: ['public'], comment: 'Old' } + ]); + }); + + it('never reads the staff review fields for a public caller', async () => { + answerFrom(STORED); + + await commentController.publicGet(listArgs({ fields: { value: ['comment', 'eaoNotes', 'eaoStatus', 'proponentNotes', 'rejectedNotes', 'rejectedReason'] } }), res); + + expect(fieldsAsked()).to.deep.equal(['comment']); + }); + + describe('by id, where the route declares no count parameter', () => { + const byIdArgs = fields => ({ + swagger: { params: { commentId: { value: VALID_COMM_ID }, fields: { value: fields } } } + }); + + it('answers an array of the one comment, reading isAnonymous to hide the author, then dropping it', async () => { + answerFrom([STORED[0]]); + + await commentController.publicGet(byIdArgs(['author', 'comment']), res); + + expect(fieldsAsked()).to.include.members(['author', 'comment', 'isAnonymous']); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal([{ _id: VALID_COMM_ID, read: ['public'], comment: 'Hi' }]); + expect(Utils.recordAction.calledWith('Get', 'Comment', 'public', VALID_COMM_ID)).to.be.true; + }); + + it('keeps the author of a named comment, and isAnonymous when asked for', async () => { + answerFrom([STORED[1]]); + + await commentController.publicGet(byIdArgs(['author', 'isAnonymous']), res); + + expect(res.json.firstCall.args[0]).to.deep.equal([{ _id: VALID_CP_ID, read: ['public'], author: 'Sam', isAnonymous: false }]); + }); + + [['an empty facet', [{ results: [], total_items: 0 }]], ['nothing', []]].forEach(([label, answer]) => { + it(`answers an empty array and records nothing when the query returns ${label}`, async () => { + Utils.runDataQuery.resolves(answer); + + await commentController.publicGet(byIdArgs(['comment']), res); + + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal([]); + expect(Utils.recordAction.called).to.be.false; + }); + }); }); }); diff --git a/test/controllers/project-locked-act.test.js b/test/controllers/project-locked-act.test.js index 727c7a08..956b9deb 100644 --- a/test/controllers/project-locked-act.test.js +++ b/test/controllers/project-locked-act.test.js @@ -199,6 +199,16 @@ describe('Registry with a made-up locked Act (2031)', () => { expect(stored.save.called).to.be.false; }); + it('refuses a non-numeric year on a 2031 project with 409, as PUT does', async () => { + storeUnder(2031, { 2031: { name: 'Coastal' } }); + + await controller.protectedPublish(publishArgs('abc'), res); + + expect(res.status.firstCall.args[0]).to.equal(409); + expect(res.json.firstCall.args[0]).to.deep.equal({ message: 'Project is under the Coastal Review Act' }); + expect(stored.save.called).to.be.false; + }); + it('refuses to move a 2018 project to 2031 when it has no 2031 content', async () => { storeUnder(2018, { 2018: { name: 'Old' } }); diff --git a/test/controllers/project-put.test.js b/test/controllers/project-put.test.js index 3658c829..1cb65c87 100644 --- a/test/controllers/project-put.test.js +++ b/test/controllers/project-put.test.js @@ -225,6 +225,25 @@ describe('Project protectedPut', () => { expect(projectModel.findOneAndUpdate.called).to.be.false; }); + [1996, 2002, 2018].forEach(year => { + it(`moves a ${year} project to the Building Canada Act`, async () => { + storedProject.currentLegislationYear = 'legislation_' + year; + storedProject.legislationYearList = [year]; + storedProject['legislation_' + year] = { name: 'Old Project', phaseHistory: [] }; + storedProject.legislation_2025 = { name: 'Old Project', phaseHistory: [] }; + const args = putArgs(); + args.swagger.params.ProjObject.value.legislationYear = 2025; + + await projectController.protectedPut(args, res); + + const fields = updateArg().$set; + expect(res.status.firstCall.args[0]).to.equal(200); + expect(fields.currentLegislationYear).to.equal('legislation_2025'); + expect(fields.legislation_2025.name).to.equal('Renamed Project'); + expect(fields.legislationYearList).to.have.members([year, 2025]); + }); + }); + describe('on a Building Canada Act project', () => { beforeEach(() => { storedProject.currentLegislationYear = 'legislation_2025'; @@ -263,7 +282,7 @@ describe('Project protectedPut', () => { expect(fields.legislation_2025.name).to.equal('Renamed Project'); }); - [2018, 2019].forEach(year => { + [1996, 2002, 2018, 2019].forEach(year => { it(`refuses with 409 and writes nothing when the request names ${year}`, async () => { const args = putArgs(); args.swagger.params.ProjObject.value.legislationYear = year; diff --git a/test/controllers/search.test.js b/test/controllers/search.test.js index db5d617f..259e3c10 100644 --- a/test/controllers/search.test.js +++ b/test/controllers/search.test.js @@ -14,6 +14,8 @@ const Actions = require('../../api/helpers/actions'); const defaultLog = require('winston').loggers.get('default'); const parentRead = require('../../api/helpers/parentRead'); +const { COMMENT_STAFF_FIELDS } = require('../../api/helpers/constants'); +const documentAggregator = require('../../api/aggregators/documentAggregator'); const searchController = require('../../api/controllers/search'); describe('Search Controller', () => { @@ -216,6 +218,77 @@ describe('Search Controller', () => { }); }); + describe('Comment item search', () => { + it('hides the author unless the comment is marked not anonymous; the field defaults to anonymous', async () => { + modelStub.aggregate.returns({ allowDiskUse: () => Promise.resolve([ + { _id: 'a', author: 'Jane' }, + { _id: 'b', author: 'Sam', isAnonymous: true }, + { _id: 'c', author: 'Lee', isAnonymous: false } + ]) }); + + await searchController.publicGet(makeArgs({ + dataset: { value: 'Item' }, + _schemaName: { value: 'Comment' }, + _id: { value: '507f1f77bcf86cd799439011' } + }), res); + + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0].map(row => row.author)).to.deep.equal([undefined, undefined, 'Lee']); + }); + + const staffFields = Object.fromEntries(COMMENT_STAFF_FIELDS.map(field => [field, `${field} value`])); + const commentItem = (overrides) => makeArgs({ + dataset: { value: 'Item' }, + _schemaName: { value: 'Comment' }, + _id: { value: '507f1f77bcf86cd799439011' }, + ...overrides + }); + + beforeEach(() => { + modelStub.aggregate.returns({ allowDiskUse: () => Promise.resolve([{ _id: 'a', comment: 'Hi', ...staffFields }]) }); + }); + + it('drops every staff field for a public caller', async () => { + await searchController.publicGet(commentItem({ + auth_payload: { realm_access: { roles: ['public'] }, preferred_username: 'public' } + }), res); + + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0][0]).to.deep.equal({ _id: 'a', comment: 'Hi' }); + }); + + it('keeps the staff fields for signed-in staff', async () => { + await searchController.protectedGet(commentItem(), res); + + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0][0]).to.include(staffFields); + }); + }); + + describe('Document search signed-in flag', () => { + // createMatchAggr takes signedIn as its ninth argument. + const signedInArg = () => documentAggregator.createMatchAggr.firstCall.args[8]; + + beforeEach(() => { + sinon.stub(documentAggregator, 'createMatchAggr').resolves([{ $match: {} }]); + }); + + it('passes signedIn true for a signed-in caller', async () => { + await searchController.protectedGet(makeArgs({ dataset: { value: 'Document' } }), res); + + expect(signedInArg()).to.equal(true); + }); + + it('passes signedIn false for a public caller', async () => { + await searchController.publicGet(makeArgs({ + dataset: { value: 'Document' }, + auth_payload: { realm_access: { roles: ['public'] }, preferred_username: 'public' } + }), res); + + expect(signedInArg()).to.equal(false); + }); + }); + describe('Item Aggregator Search', () => { it('returns item when dataset is ITEM', async () => { const args = makeArgs({ diff --git a/test/dao/projectDAO.test.js b/test/dao/projectDAO.test.js index bcb25500..d7d77945 100644 --- a/test/dao/projectDAO.test.js +++ b/test/dao/projectDAO.test.js @@ -5,11 +5,10 @@ const Actions = require('../../api/helpers/actions'); const Utils = require('../../api/helpers/utils'); const projectDAO = require('../../api/dao/projectDAO'); -describe('projectDAO publish audit', () => { - const project = { _id: new mongoose.Types.ObjectId(), legislationYear: 2018 }; +describe('projectDAO unpublish audit', () => { + const project = { _id: new mongoose.Types.ObjectId() }; beforeEach(() => { - sinon.stub(Actions, 'publish').resolves(project); sinon.stub(Actions, 'unPublish').resolves(project); sinon.stub(Utils, 'recordAction').resolves(); }); @@ -20,9 +19,4 @@ describe('projectDAO publish audit', () => { await projectDAO.unPublishProject('user', project); expect(Utils.recordAction.calledWith('Unpublish', 'Project', 'user', project._id)).to.be.true; }); - - it('publishProject records action Publish on Project', async () => { - await projectDAO.publishProject('user', project); - expect(Utils.recordAction.calledWith('Publish', 'Project', 'user', project._id)).to.be.true; - }); });