diff --git a/acl/dmz.json b/acl/dmz.json index d2b7a82..2eb3332 100644 --- a/acl/dmz.json +++ b/acl/dmz.json @@ -241,6 +241,21 @@ } ] }, + "meeting_files": { + "doc": "List a meeting share's attachments, authorised by the token alone. Answers names and sizes only; downloads go through file/orig under the link identity's grant. A password-protected link answers REQUIRED_PASSWORD until the session has passed the password.", + "scope": "hub", + "permission": { + "src": "anonymous", + "fast_check": "public-api" + }, + "params": { + "token": { + "type": "string", + "required": true, + "description": "Meeting share token" + } + } + }, "list_by_token": { "doc": "Read-only listing of a secure share's contents, authorised by the TOKEN alone. Exists because dmz.login refuses to bind a share identity onto a main-domain session (regsid guard), so a page served from the main domain can never obtain the grant media.show_node_by needs. This never touches the caller's session: no cookie_touch, no grant, no identity change. The listing target is derived from the token, never from client input. Refuses any share that is not plainly open — revoked, expired, invalid, locked, password-gated or email-gated all return a status and no items, because this endpoint performs no gate. A file share lists its parent folder hard-filtered to that file. Privileges are the anonymous guest's, clamped to the share's capabilities; sender-only fields are never echoed.", "scope": "hub", diff --git a/acl/room.json b/acl/room.json index 3605e1b..3eb3106 100644 --- a/acl/room.json +++ b/acl/room.json @@ -378,6 +378,26 @@ "errors": [] }, + "link_files": { + "doc": "Attach already-uploaded files (media nids in this hub) to a meeting the caller created. Appends to the meeting's attachment list (deduped, max 20) and, when the meeting already has a public link, grants that link download access to the new files. Returns the full list and any nids refused for the cap.", + "scope": "hub", + "permission": { + "src": "write", + "fast_check": "user_permission" + }, + "params": { + "nid": { + "type": "string", + "required": true, + "description": "Meeting (schedule) node id" + }, + "file_nids": { + "type": "array", + "required": true, + "description": "Media node ids to attach" + } + } + }, "public_link": { "doc": "Generate a public shareable link for a meeting room node. Creates a DMZ grant token for the specified node, grants permission to the public_id system user, and returns a shareable URL containing the access token.", "scope": "hub", diff --git a/service/dmz.js b/service/dmz.js index 9024f49..ae90111 100644 --- a/service/dmz.js +++ b/service/dmz.js @@ -22,6 +22,7 @@ const { } = Constants; const { verifyPassword: verifySecureSharePassword } = require('./lib/secure-share-password'); const { secureShareCapPrivilege } = require('./lib/secure-share-write-guard'); +const { attachmentsOf, isMeetingNode } = require('./lib/meeting-attachments'); const Jwt = require('jsonwebtoken'); const { resolve: _resolvePath } = require('path'); const { existsSync, readFileSync, statSync } = require('fs'); @@ -1474,6 +1475,65 @@ class __dmz extends Mfs { }); } + /** + * A meeting link's attachments, authorised by the TOKEN alone — the + * companion to list_by_token for a `schedule` share. Names only; the bytes + * come from file/orig, which room.public_link / room.link_files opened to the + * link identity. + * + * A password-protected (or locked, or email-gated) link answers nothing: + * _shareByToken refuses gated shares outright, so the token alone never + * leaks the file names the password is meant to protect. + * + * Every nid comes through attachmentsOf, which only admits /^[0-9a-f]{16}$/, + * so interpolating it into the forward_proc argument string is safe. + * + * Input: token {String} required + * Output: { status, hub_id?, items[] } + */ + async meeting_files() { + const token = this.input.need(Attr.token); + const deny = (status) => this.output.data({ status, items: [] }); + const share = await this._shareByToken(token, 'dmz.meeting_files'); + if (share.status) return deny(share.status); + const { info } = share; + const nid = info.node_id || info.nid; + const attr = async (id) => + toArray(await this.yp.await_proc('forward_proc', info.hub_id, 'mfs_node_attr', `'${id}'`))[0] || {}; + let node; + try { + node = await attr(nid); + } catch (e) { + this.warn('[dmz.meeting_files] meeting lookup failed:', e && e.message); + return deny('TICKET_INVALID'); + } + if (!isMeetingNode(node)) return deny('NOT_A_MEETING'); + let meta = {}; + try { + meta = JSON.parse(node.metadata || '{}') || {}; + } catch (e) { + meta = {}; + } + const items = []; + for (const id of attachmentsOf(meta.content)) { + let a; + try { + a = await attr(id); + } catch (e) { + continue; + } + if (!a || !(a.id || a.nid)) continue; + items.push({ + nid: a.id || a.nid, + filename: a.filename || a.user_filename || '', + ext: a.ext || a.extension || '', + filetype: a.filetype || a.ftype || '', + filesize: a.filesize || 0, + }); + } + this.output.data({ status: 'TICKET_OK', hub_id: info.hub_id, items }); + } + /** * Read-only workspace chat for a share, authorised BY THE TOKEN ALONE. * diff --git a/service/lib/meeting-attachments.js b/service/lib/meeting-attachments.js new file mode 100644 index 0000000..4635f09 --- /dev/null +++ b/service/lib/meeting-attachments.js @@ -0,0 +1,56 @@ +// The nid list a meeting (`schedule` node) keeps in metadata.content.attachments. +// +// Pure: no DB, no session. room.js and dmz.js both read through here, so the +// rule for what counts as a valid attachment id lives in one place — and +// because every nid is checked against NID_RE, callers may interpolate them +// into forward_proc argument strings (dmz.js does). +const NID_RE = /^[0-9a-f]{16}$/; +const MAX_ATTACHMENTS = 20; + +function parse(v) { + if (typeof v !== "string") return v; + try { + return JSON.parse(v); + } catch (e) { + return v; + } +} + +function normalizeNids(input) { + let v = parse(input); + if (v == null) return []; + if (!Array.isArray(v)) v = [v]; + const out = []; + for (const n of v) { + if (typeof n !== "string" || !NID_RE.test(n) || out.includes(n)) continue; + out.push(n); + } + return out; +} + +function attachmentsOf(content) { + const c = parse(content); + return normalizeNids(c && typeof c === "object" ? c.attachments : null); +} + +function mergeAttachments(existing, incoming) { + const list = normalizeNids(existing); + const added = []; + const overflow = []; + for (const n of normalizeNids(incoming)) { + if (list.includes(n)) continue; + if (list.length >= MAX_ATTACHMENTS) { + overflow.push(n); + continue; + } + list.push(n); + added.push(n); + } + return { list, added, overflow }; +} + +function isMeetingNode(node) { + return !!(node && (node.filetype === "schedule" || node.category === "schedule")); +} + +module.exports = { NID_RE, MAX_ATTACHMENTS, normalizeNids, attachmentsOf, mergeAttachments, isMeetingNode }; diff --git a/service/private/room.js b/service/private/room.js index 2456bb2..3a7dbfe 100644 --- a/service/private/room.js +++ b/service/private/room.js @@ -18,6 +18,7 @@ const { isEmpty, isArray, difference, map } = require('lodash'); const __public_room = require("../room"); const { Attr, Privilege, Cache, sysEnv, RedisStore, toArray } = require("@drumee/server-essentials") const { memberCan, CAN_WRITE } = require("../lib/member-capability"); +const { normalizeNids, attachmentsOf, mergeAttachments, isMeetingNode } = require("../lib/meeting-attachments"); //######################################## class __private_room extends __public_room { @@ -102,6 +103,16 @@ class __private_room extends __public_room { await this.db.await_proc('permission_grant', nid, public_id, expiry, permission, 'link', '' ); + // The link opens the meeting; its attachments have to open too, or a guest + // sees file names they cannot download. Same identity and expiry as the + // meeting grant, download tier only (Privilege.download, never write). + const meeting = await this.db.await_proc('mfs_node_attr', nid); + const meta = this.parseJSON((meeting && meeting.metadata) || '{}') || {}; + for (const file_nid of attachmentsOf(meta.content)) { + await this.db.await_proc('permission_grant', + file_nid, public_id, expiry, Privilege.download, 'link', '' + ); + } this.debug("AAAA:104", p) let link = this._getShareLink(p.token) this.output.data({ link }); @@ -202,6 +213,10 @@ class __private_room extends __public_room { let stime = content.stime let etime = content.etime let recur = content.recur + // Not editable through update() — room.link_files owns the list — but it + // has to SURVIVE an edit: the Meet tab saves with flag 'all' and the + // content object below is rebuilt field by field. + const attachments = attachmentsOf(content); // The start time BEFORE this edit, so a real reschedule can be told apart // from a save that left the time alone. The scheduler posts the whole form @@ -262,7 +277,7 @@ class __private_room extends __public_room { nid, { content: { - attendees, title, message, date, stime, etime, recur, + attendees, title, message, date, stime, etime, recur, attachments, created_by: content.created_by, room_id: nid }, room_status: 'booked' @@ -339,7 +354,7 @@ class __private_room extends __public_room { } } content = { - attendees, title, message, date, stime, etime, recur, + attendees, title, message, date, stime, etime, recur, attachments, created_by: content.created_by }; // Keep the global reminder index in lockstep with the edited meeting @@ -348,6 +363,55 @@ class __private_room extends __public_room { await this.output.data((content)); } + /** + * Attach already-uploaded files to a meeting. Owner-only, like update(). + * Appends to metadata.content.attachments (deduped, capped — see + * lib/meeting-attachments). When the meeting already has a public link, the + * new files get the same download grant public_link gives (public_link + * itself grants whatever is attached at the time it runs). + * Params: nid (meeting), file_nids (array of media nids in this hub). + */ + async link_files() { + // Same gate as book(): the ACL's fast_check skips its declared `src`. + if (!(await memberCan(this, CAN_WRITE))) { + return this.exception.forbiden(); + } + const nid = this.input.need(Attr.nid); + const incoming = normalizeNids(this.input.need('file_nids')); + const node = await this.db.await_proc('mfs_node_attr', nid); + if (!isMeetingNode(node)) return this.exception.user("MEETING_NOT_FOUND"); + const metadata = this.parseJSON(node.metadata || '{}') || {}; + const content = this.parseJSON(metadata.content || '{}') || {}; + // Stricter than update(): attaching opens files to the link, so a legacy + // meeting with no recorded creator has nobody entitled to do it. + if (content.created_by !== this.uid) { + return this.exception.user("NOT_MEETING_OWNER"); + } + const files = []; + for (const f of incoming) { + const a = await this.db.await_proc('mfs_node_attr', f); + if (!a || !(a.id || a.nid)) continue; + if (['folder', 'hub', 'root', 'schedule'].includes(a.filetype)) continue; + // The link will be able to download it, so the caller must be able to. + const mine = await this.db.await_proc('mfs_access_node', this.uid, f); + if (!mine || Number(mine.privilege) < Privilege.download) continue; + files.push(f); + } + const { list, added, overflow } = mergeAttachments(attachmentsOf(content), files); + await this.db.await_proc('mfs_set_metadata', nid, { + content: { ...content, attachments: list }, + room_status: metadata.room_status || 'booked', + }, 1); + const public_id = Cache.getSysConf('public_id'); + const pub = await this.db.await_proc('mfs_access_node', public_id, nid); + if (pub && Number(pub.privilege) > 0) { + for (const f of added) { + await this.db.await_proc('permission_grant', f, public_id, 0, Privilege.download, 'link', ''); + } + } + this.output.data({ nid, attachments: list, overflow }); + } + /** * Persist a scheduled-meeting notice for each recipient (Duy 2026-08-21, * issues 9 and 11). @@ -588,6 +652,13 @@ class __private_room extends __public_room { folder_name: await this._meeting_folder_name(node && node.parent_id), }); } + // The link identity's download grant on each attachment goes with the + // meeting. The files themselves stay in the hidden task folder (no + // reference counting for meetings yet). + const public_id = Cache.getSysConf('public_id'); + for (const file_nid of attachmentsOf(content)) { + await this.db.await_proc('permission_revoke', file_nid, public_id); + } await this.db.await_proc('permission_revoke', nid, "meeting"); await this._unindex_meeting(nid); this.output.data({ nid }); diff --git a/test/dmz-meeting-files.test.js b/test/dmz-meeting-files.test.js new file mode 100644 index 0000000..cff9688 --- /dev/null +++ b/test/dmz-meeting-files.test.js @@ -0,0 +1,75 @@ +/** + * Cover for dmz.meeting_files. + * Run: node test/dmz-meeting-files.test.js + */ +const assert = require("assert"); +const path = require("path"); +const fs = require("fs"); + +// dmz.js has a heavy require graph; slice the one method out and run it +// against a stub `this` (the ui-team tests/helpers/slice-method technique). +const SRC = fs.readFileSync(path.join(__dirname, "../service/dmz.js"), "utf8"); +function slice(sig) { + const start = SRC.indexOf(`\n ${sig} {`); + if (start < 0) throw new Error(`${sig} not found`); + const end = SRC.indexOf("\n }\n", start); + return SRC.slice(start, end + 4).trim().replace(/^async\s+([A-Za-z_]\w*)\s*\(/, "async function $1("); +} +const lib = require("../service/lib/meeting-attachments"); +const toArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]); +const meeting_files = new Function("toArray", "attachmentsOf", "isMeetingNode", "Attr", + `return ${slice("async meeting_files()")}`)(toArray, lib.attachmentsOf, lib.isMeetingNode, { token: "token" }); + +// The real share resolver, so the password gate is the one production runs. +const shareByToken = new Function("toArray", `return ${slice("async _shareByToken(token, tag)")}`)(toArray); + +const MEET = "aaaaaaaaaaaaaaaa", F1 = "1111111111111111"; +function make({ share, legacy, nodes = {}, viewerPriv = 0 }) { + const out = {}; + const self = { + uid: "vvvvvvvvvvvvvvvv", + input: { need: () => "tok" }, + _shareByToken: legacy ? shareByToken : async () => share, + yp: { await_proc: async (name, _hub, proc, args) => { + if (name === "secure_share_info") return []; + if (name === "dmz_info_next") return legacy; + const id = String(args).replace(/'/g, ""); + return proc === "mfs_node_attr" ? [nodes[id] || {}] : []; + } }, + db: { await_proc: async () => ({ privilege: viewerPriv }) }, + output: { data: (d) => { out.data = d; } }, + warn: () => {}, + }; + return { run: () => meeting_files.call(self), out }; +} +const meetingNode = (attachments) => ({ id: MEET, filetype: "schedule", + metadata: JSON.stringify({ content: JSON.stringify({ attachments }) }) }); + +(async () => { + { + const t = make({ share: { info: { hub_id: "h", nid: MEET } }, + nodes: { [MEET]: meetingNode([F1]), [F1]: { id: F1, filename: "a", ext: "pdf", filetype: "document", filesize: 9, owner_id: "secret" } } }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "TICKET_OK", hub_id: "h", + items: [{ nid: F1, filename: "a", ext: "pdf", filetype: "document", filesize: 9 }] }); + } + { + const t = make({ share: { status: "TICKET_INVALID" } }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "TICKET_INVALID", items: [] }); + } + { + const t = make({ share: { info: { hub_id: "h", nid: F1 } }, nodes: { [F1]: { id: F1, filetype: "folder" } } }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "NOT_A_MEETING", items: [] }); + } + // Review Focus 4: password-protected link without access lists nothing + { + // viewerPriv 15: even a session holding the meeting grant gets nothing. + const t = make({ legacy: { hub_id: "h", nid: MEET, validity: "TICKET_OK", require_password: 1 }, + nodes: { [MEET]: meetingNode([F1]) }, viewerPriv: 15 }); + await t.run(); + assert.deepStrictEqual(t.out.data, { status: "REQUIRED_PASSWORD", items: [] }); + } + console.log("dmz-meeting-files: ok"); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test/meeting-attachments.test.js b/test/meeting-attachments.test.js new file mode 100644 index 0000000..11a90e8 --- /dev/null +++ b/test/meeting-attachments.test.js @@ -0,0 +1,39 @@ +/** + * Cover for service/lib/meeting-attachments — the nid list a meeting node + * keeps in metadata.content.attachments. + * Run: node test/meeting-attachments.test.js + */ +const assert = require("assert"); +const m = require("../service/lib/meeting-attachments"); + +const A = "0123456789abcdef", B = "fedcba9876543210", C = "00000000000000aa"; + +// normalizeNids: shapes, validation, order, dedupe +assert.deepStrictEqual(m.normalizeNids([A, B, A]), [A, B]); +assert.deepStrictEqual(m.normalizeNids(JSON.stringify([B, A])), [B, A]); +assert.deepStrictEqual(m.normalizeNids(A), [A]); +assert.deepStrictEqual(m.normalizeNids(["x'); DROP", A.toUpperCase(), null, 5, A]), [A]); +assert.deepStrictEqual(m.normalizeNids(undefined), []); +assert.deepStrictEqual(m.normalizeNids("not json ["), []); + +// attachmentsOf: object or JSON content, missing field +assert.deepStrictEqual(m.attachmentsOf({ attachments: [A] }), [A]); +assert.deepStrictEqual(m.attachmentsOf(JSON.stringify({ attachments: [B] })), [B]); +assert.deepStrictEqual(m.attachmentsOf({ title: "x" }), []); +assert.deepStrictEqual(m.attachmentsOf(null), []); + +// mergeAttachments: append, skip existing, cap +assert.deepStrictEqual(m.mergeAttachments([A], [A, B]), { list: [A, B], added: [B], overflow: [] }); +const many = Array.from({ length: m.MAX_ATTACHMENTS }, (_, i) => i.toString(16).padStart(16, "0")); +const r = m.mergeAttachments(many, [C]); +assert.strictEqual(r.list.length, m.MAX_ATTACHMENTS); +assert.deepStrictEqual(r.added, []); +assert.deepStrictEqual(r.overflow, [C]); + +// isMeetingNode +assert.strictEqual(m.isMeetingNode({ filetype: "schedule" }), true); +assert.strictEqual(m.isMeetingNode({ category: "schedule" }), true); +assert.strictEqual(m.isMeetingNode({ filetype: "folder" }), false); +assert.strictEqual(m.isMeetingNode(null), false); + +console.log("meeting-attachments: ok"); diff --git a/test/room-link-files.test.js b/test/room-link-files.test.js new file mode 100644 index 0000000..99809cf --- /dev/null +++ b/test/room-link-files.test.js @@ -0,0 +1,171 @@ +/** + * Cover for room.link_files and the attachment-preserving edits to + * room.update / public_link / remove. + * Run: node test/room-link-files.test.js + */ +const assert = require("assert"); +const path = require("path"); + +const stub = (rel, exports) => { + const p = require.resolve(path.join(__dirname, "..", rel)); + require.cache[p] = { id: p, filename: p, loaded: true, exports }; +}; +const PUBLIC = "360deefd360def00"; +require.cache[require.resolve("@drumee/server-essentials")] = { + exports: { + Attr: { id: "id", nid: "nid", flag: "flag", password: "password", days: "days", hours: "hours", + permission: "permission", title: "title", date: "date", message: "message", stime: "stime", + etime: "etime", attendees: "attendees", profile: "profile" }, + Privilege: { write: 15, download: 7 }, + Cache: { getSysConf: (k) => (k === "public_id" ? PUBLIC : null), message: () => "" }, + sysEnv: () => ({}), RedisStore: {}, toArray: (v) => (Array.isArray(v) ? v : v == null ? [] : [v]), + }, +}; +stub("service/room.js", class {}); +let canWrite = true; +stub("service/lib/member-capability.js", { memberCan: async () => canWrite, CAN_WRITE: 2 }); +const Room = require("../service/private/room"); + +const MEET = "aaaaaaaaaaaaaaaa", F1 = "1111111111111111", F2 = "2222222222222222", OWNER = "uuuuuuuuuuuuuuuu"; + +function make({ input = {}, nodes = {}, publicPriv = 0, callerPriv = 63, uid = OWNER } = {}) { + const calls = []; + const out = {}; + const self = Object.create(Room.prototype); + Object.assign(self, { + uid, + hub: { get: () => "hhhhhhhhhhhhhhhh" }, + user: { get: () => "Org", locale_message: () => ({ format: () => "" }) }, + input: { + need: (k) => { if (input[k] == null) throw new Error(`missing ${k}`); return input[k]; }, + use: (k, d) => (input[k] == null ? d : input[k]), + get: (k) => input[k], + }, + parseJSON: (v) => (typeof v === "string" ? JSON.parse(v) : v), + db: { + await_proc: async (name, ...args) => { + calls.push([name, ...args]); + if (name === "mfs_node_attr") return nodes[args[0]] || {}; + if (name === "mfs_access_node") return { privilege: args[0] === PUBLIC ? publicPriv : callerPriv }; + return {}; + }, + }, + yp: { await_proc: async (name, ...args) => { calls.push([name, ...args]); return { token: "t" }; } }, + exception: { user: (code) => { out.error = code; }, forbiden: () => { out.error = "FORBIDDEN"; } }, + output: { data: (d) => { out.data = d; } }, + randomString: () => "r", + debug: () => {}, + _index_meeting: async () => {}, + _unindex_meeting: async () => {}, + _meeting_folder_name: async () => "", + _getShareLink: () => "link", + }); + return { self, calls, out }; +} + +const meetingNode = (content) => ({ + id: MEET, filetype: "schedule", + metadata: JSON.stringify({ content: JSON.stringify(content), room_status: "booked" }), +}); +const fileNode = (id) => ({ id, filetype: "document", filename: "f" }); + +(async () => { + // link_files appends, writes metadata, no grant without a public link + { + const { self, calls, out } = make({ + input: { nid: MEET, file_nids: [F1, F2] }, + nodes: { [MEET]: meetingNode({ title: "M", created_by: OWNER }), [F1]: fileNode(F1), [F2]: fileNode(F2) }, + }); + await self.link_files(); + assert.deepStrictEqual(out.data, { nid: MEET, attachments: [F1, F2], overflow: [] }); + const set = calls.find((c) => c[0] === "mfs_set_metadata"); + assert.deepStrictEqual(set[2].content.attachments, [F1, F2]); + assert.strictEqual(set[2].content.title, "M"); + assert.ok(!calls.some((c) => c[0] === "permission_grant")); + } + // link_files mirrors an existing public grant onto the new files only + { + const { self, calls } = make({ + input: { nid: MEET, file_nids: [F1, F2] }, publicPriv: 15, + nodes: { [MEET]: meetingNode({ created_by: OWNER, attachments: [F1] }), [F1]: fileNode(F1), [F2]: fileNode(F2) }, + }); + await self.link_files(); + const grants = calls.filter((c) => c[0] === "permission_grant"); + assert.deepStrictEqual(grants.map((g) => g.slice(1)), [[F2, PUBLIC, 0, 7, "link", ""]]); + } + // link_files: not the owner / not a meeting / a folder nid + { + const a = make({ input: { nid: MEET, file_nids: [F1] }, uid: "someoneelse00000", + nodes: { [MEET]: meetingNode({ created_by: OWNER }) } }); + await a.self.link_files(); + assert.strictEqual(a.out.error, "NOT_MEETING_OWNER"); + const b = make({ input: { nid: F1, file_nids: [F2] }, nodes: { [F1]: fileNode(F1) } }); + await b.self.link_files(); + assert.strictEqual(b.out.error, "MEETING_NOT_FOUND"); + const c = make({ input: { nid: MEET, file_nids: [F1] }, + nodes: { [MEET]: meetingNode({ created_by: OWNER }), [F1]: { id: F1, filetype: "folder" } } }); + await c.self.link_files(); + assert.deepStrictEqual(c.out.data.attachments, []); + } + // Final review C1: a member who may not write is refused outright + { + canWrite = false; + const { self, calls, out } = make({ input: { nid: MEET, file_nids: [F1] }, + nodes: { [MEET]: meetingNode({ created_by: OWNER }), [F1]: fileNode(F1) } }); + await self.link_files(); + canWrite = true; + assert.strictEqual(out.error, "FORBIDDEN"); + assert.ok(!calls.some((c) => c[0] === "mfs_set_metadata")); + } + // Final review C1: a file the caller cannot download is never attached (nor granted) + { + const { self, calls, out } = make({ input: { nid: MEET, file_nids: [F1] }, callerPriv: 3, publicPriv: 15, + nodes: { [MEET]: meetingNode({ created_by: OWNER }), [F1]: fileNode(F1) } }); + await self.link_files(); + assert.deepStrictEqual(out.data.attachments, []); + assert.ok(!calls.some((c) => c[0] === "permission_grant")); + } + // Final review C1: a legacy meeting with no recorded creator has no owner to act for + { + const { self, out } = make({ input: { nid: MEET, file_nids: [F1] }, + nodes: { [MEET]: meetingNode({ title: "legacy" }), [F1]: fileNode(F1) } }); + await self.link_files(); + assert.strictEqual(out.error, "NOT_MEETING_OWNER"); + } + // update keeps attachments (Review Focus 1) + { + const { self, calls, out } = make({ + input: { nid: MEET, flag: "all", title: "New", message: "", attendees: [] }, + nodes: { [MEET]: meetingNode({ title: "Old", created_by: OWNER, attachments: [F1] }) }, + }); + await self.update(); + const set = calls.find((c) => c[0] === "mfs_set_metadata"); + assert.deepStrictEqual(set[2].content.attachments, [F1]); + assert.deepStrictEqual(out.data.attachments, [F1]); + } + // public_link grants every attachment with the link's expiry + { + const { self, calls } = make({ + input: { nid: MEET, hours: 2 }, + nodes: { [MEET]: meetingNode({ created_by: OWNER, attachments: [F1, F2] }) }, + }); + await self.public_link(); + const grants = calls.filter((c) => c[0] === "permission_grant").map((g) => g.slice(1)); + assert.deepStrictEqual(grants, [ + [MEET, PUBLIC, 2, 15, "link", ""], + [F1, PUBLIC, 2, 7, "link", ""], + [F2, PUBLIC, 2, 7, "link", ""], + ]); + } + // remove revokes the link identity's grant on each attachment + { + const { self, calls } = make({ + input: { nid: MEET }, + nodes: { [MEET]: meetingNode({ created_by: OWNER, attachments: [F1] }) }, + }); + await self.remove(); + const revokes = calls.filter((c) => c[0] === "permission_revoke").map((c) => c.slice(1)); + assert.deepStrictEqual(revokes, [[F1, PUBLIC], [MEET, "meeting"]]); + } + console.log("room-link-files: ok"); +})().catch((e) => { console.error(e); process.exit(1); });