diff --git a/src/handlers/delete.js b/src/handlers/delete.js index f44946ed..c2a6bf21 100644 --- a/src/handlers/delete.js +++ b/src/handlers/delete.js @@ -10,11 +10,13 @@ * governing permissions and limitations under the License. */ import { deleteSource } from '../routes/source.js'; +import { deleteComments } from '../routes/comments.js'; export default async function deleteHandler({ req, env, daCtx }) { const { path } = daCtx; if (path.startsWith('/source')) return deleteSource({ req, env, daCtx }); + if (path.startsWith('/comments')) return deleteComments({ req, env, daCtx }); return undefined; } diff --git a/src/handlers/post.js b/src/handlers/post.js index 2dc35725..1271b96f 100644 --- a/src/handlers/post.js +++ b/src/handlers/post.js @@ -12,6 +12,7 @@ import { postSource } from '../routes/source.js'; import { postConfig } from '../routes/config.js'; import { postVersionSource } from '../routes/version.js'; +import { postComments } from '../routes/comments.js'; import copyHandler from '../routes/copy.js'; import logout from '../routes/logout.js'; import moveRoute from '../routes/move.js'; @@ -23,6 +24,7 @@ export default async function postHandler({ req, env, daCtx }) { if (path.startsWith('/source')) return postSource({ req, env, daCtx }); if (path.startsWith('/config')) return postConfig({ req, env, daCtx }); if (path.startsWith('/versionsource')) return postVersionSource({ req, env, daCtx }); + if (path.startsWith('/comments')) return postComments({ req, env, daCtx }); if (path.startsWith('/copy')) return copyHandler({ req, env, daCtx }); if (path.startsWith('/move')) return moveRoute({ req, env, daCtx }); if (path.startsWith('/logout')) return logout({ env, daCtx }); diff --git a/src/helpers/comments.js b/src/helpers/comments.js new file mode 100644 index 00000000..73641603 --- /dev/null +++ b/src/helpers/comments.js @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const ANCHOR_TYPES = new Set(['text', 'image', 'table']); +const MAX_BODY_LENGTH = 10 * 1024; + +export function parseCommentsPath(path) { + // /comments/{org}/{site}/{docId}/threads[/...] + const parts = path.split('/').filter(Boolean); + if (parts[0] !== 'comments') return null; + const [, org, site, docId, ...rest] = parts; + if (!org || !site || !docId) return null; + return { + org, site, docId, rest, + }; +} + +export function commentsFileKey(site, docId) { + return `${site}/.da/comments/${docId}.json`; +} + +export function getAuthor(daCtx) { + const user = daCtx.users?.[0]; + if (!user || user.email === 'anonymous') return null; + return { + id: user.ident ?? user.email, + name: user.name ?? user.email, + email: user.email, + }; +} + +/** + * Validates a comment write body. Returns `{ ok: true }` on success or + * `{ ok: false }` on failure. + * + * @param body - parsed request body (may be null if JSON parse failed) + * @param requireAnchor - true for new threads, false for replies + */ +export function validateCommentBody(body, { requireAnchor }) { + if (!body + || typeof body.id !== 'string' + || typeof body.body !== 'string' + || !Number.isFinite(body.createdAt)) { + return { ok: false }; + } + const trimmed = body.body.trim(); + if (trimmed.length === 0 || trimmed.length > MAX_BODY_LENGTH) { + return { ok: false }; + } + if (requireAnchor && (!body.anchor || !ANCHOR_TYPES.has(body.anchor.anchorType))) { + return { ok: false }; + } + return { ok: true }; +} + +/* + * Mutator builders. Each returns a `mutate(state)` function suitable for + * `atomicMutation`. The mutator either mutates `state` in place and returns a + * success payload, or returns `{ error, status }` to short-circuit the write. + */ + +export function addThreadMutator({ + id, anchor, body, createdAt, author, +}) { + return (state) => { + if (state.threads[id]) return { error: 'thread_exists', status: 409 }; + // eslint-disable-next-line no-param-reassign + state.threads[id] = { + id, + anchorFrom: anchor.anchorFrom, + anchorTo: anchor.anchorTo, + anchorType: anchor.anchorType, + anchorText: anchor.anchorText ?? '', + author, + body, + createdAt, + resolved: false, + resolvedBy: null, + resolvedAt: null, + reopenedBy: null, + reopenedAt: null, + replies: [], + }; + return { id }; + }; +} + +export function addReplyMutator({ + threadId, id, body, createdAt, author, +}) { + return (state) => { + const thread = state.threads[threadId]; + if (!thread) return { error: 'thread_not_found', status: 404 }; + if ((thread.replies ?? []).some((r) => r.id === id)) { + return { error: 'reply_exists', status: 409 }; + } + thread.replies = [...(thread.replies ?? []), { + id, author, body, createdAt, + }]; + return { id }; + }; +} + +export function resolveThreadMutator({ threadId, actor, now = Date.now() }) { + return (state) => { + const thread = state.threads[threadId]; + if (!thread) return { error: 'thread_not_found', status: 404 }; + Object.assign(thread, { + resolved: true, + resolvedBy: actor, + resolvedAt: now, + reopenedBy: null, + reopenedAt: null, + }); + return thread; + }; +} + +export function unresolveThreadMutator({ threadId, actor, now = Date.now() }) { + return (state) => { + const thread = state.threads[threadId]; + if (!thread) return { error: 'thread_not_found', status: 404 }; + Object.assign(thread, { + resolved: false, + resolvedBy: null, + resolvedAt: null, + reopenedBy: actor, + reopenedAt: now, + }); + return thread; + }; +} + +export function deleteThreadMutator({ threadId }) { + return (state) => { + if (!state.threads[threadId]) return { error: 'thread_not_found', status: 404 }; + // eslint-disable-next-line no-param-reassign + delete state.threads[threadId]; + return {}; + }; +} + +export function deleteReplyMutator({ threadId, replyId }) { + return (state) => { + const thread = state.threads[threadId]; + if (!thread) return { error: 'thread_not_found', status: 404 }; + const replies = thread.replies ?? []; + if (!replies.some((r) => r.id === replyId)) { + return { error: 'reply_not_found', status: 404 }; + } + thread.replies = replies.filter((r) => r.id !== replyId); + return {}; + }; +} diff --git a/src/routes/comments.js b/src/routes/comments.js new file mode 100644 index 00000000..86fef240 --- /dev/null +++ b/src/routes/comments.js @@ -0,0 +1,186 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { atomicMutation } from '../storage/object/comments.js'; +import { hasPermission } from '../utils/auth.js'; +import { + parseCommentsPath, + commentsFileKey, + getAuthor, + validateCommentBody, + addThreadMutator, + addReplyMutator, + resolveThreadMutator, + unresolveThreadMutator, + deleteThreadMutator, + deleteReplyMutator, +} from '../helpers/comments.js'; + +function errResponse(status, error) { + return { + status, + body: JSON.stringify({ error }), + contentType: 'application/json', + }; +} + +function okResponse(status, payload) { + return { + status, + body: JSON.stringify(payload), + contentType: 'application/json', + }; +} + +/** + * Common auth gate for all comment write endpoints. Returns either + * - `{ actor, fileKey }` on success, OR + * - `{ response }` with a 401/403 error response if the request should be + * short-circuited. + */ +function checkWriteAuth(daCtx, parsed) { + const fileKey = commentsFileKey(parsed.site, parsed.docId); + if (!hasPermission(daCtx, `/${fileKey}`, 'write')) { + return { response: errResponse(403, 'forbidden') }; + } + const actor = getAuthor(daCtx); + if (!actor) return { response: errResponse(401, 'unauthenticated') }; + return { actor, fileKey }; +} + +function mapMutationResult(result, successStatus, successPayload) { + if (!result.ok) return errResponse(result.status ?? 500, result.error ?? 'internal_error'); + if (successStatus === 204) return { status: 204 }; + return okResponse(successStatus, successPayload ?? result.result); +} + +async function addThread({ + req, env, daCtx, parsed, +}) { + const auth = checkWriteAuth(daCtx, parsed); + if (auth.response) return auth.response; + + const body = await req.json().catch(() => null); + if (!validateCommentBody(body, { requireAnchor: true }).ok) { + return errResponse(400, 'invalid_body'); + } + + const result = await atomicMutation(env, parsed.org, auth.fileKey, addThreadMutator({ + id: body.id, + anchor: body.anchor, + body: body.body, + createdAt: body.createdAt, + author: auth.actor, + })); + return mapMutationResult(result, 201); +} + +async function addReply({ + req, env, daCtx, parsed, +}) { + const auth = checkWriteAuth(daCtx, parsed); + if (auth.response) return auth.response; + + const body = await req.json().catch(() => null); + if (!validateCommentBody(body, { requireAnchor: false }).ok) { + return errResponse(400, 'invalid_body'); + } + + const result = await atomicMutation(env, parsed.org, auth.fileKey, addReplyMutator({ + threadId: parsed.rest[1], + id: body.id, + body: body.body, + createdAt: body.createdAt, + author: auth.actor, + })); + return mapMutationResult(result, 201); +} + +async function resolveThread({ env, daCtx, parsed }) { + const auth = checkWriteAuth(daCtx, parsed); + if (auth.response) return auth.response; + + const result = await atomicMutation(env, parsed.org, auth.fileKey, resolveThreadMutator({ + threadId: parsed.rest[1], + actor: auth.actor, + })); + return mapMutationResult(result, 200); +} + +async function unresolveThread({ env, daCtx, parsed }) { + const auth = checkWriteAuth(daCtx, parsed); + if (auth.response) return auth.response; + + const result = await atomicMutation(env, parsed.org, auth.fileKey, unresolveThreadMutator({ + threadId: parsed.rest[1], + actor: auth.actor, + })); + return mapMutationResult(result, 200); +} + +async function deleteThread({ env, daCtx, parsed }) { + const auth = checkWriteAuth(daCtx, parsed); + if (auth.response) return auth.response; + + const result = await atomicMutation(env, parsed.org, auth.fileKey, deleteThreadMutator({ + threadId: parsed.rest[1], + })); + return mapMutationResult(result, 204); +} + +async function deleteReply({ env, daCtx, parsed }) { + const auth = checkWriteAuth(daCtx, parsed); + if (auth.response) return auth.response; + + const result = await atomicMutation(env, parsed.org, auth.fileKey, deleteReplyMutator({ + threadId: parsed.rest[1], + replyId: parsed.rest[3], + })); + return mapMutationResult(result, 204); +} + +export async function postComments({ req, env, daCtx }) { + const parsed = parseCommentsPath(daCtx.path); + if (!parsed) return errResponse(400, 'invalid_path'); + + const { rest } = parsed; + if (rest.length === 1 && rest[0] === 'threads') { + return addThread({ + req, env, daCtx, parsed, + }); + } + if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'replies') { + return addReply({ + req, env, daCtx, parsed, + }); + } + if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'resolve') { + return resolveThread({ env, daCtx, parsed }); + } + if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'unresolve') { + return unresolveThread({ env, daCtx, parsed }); + } + return errResponse(404, 'unknown_endpoint'); +} + +export async function deleteComments({ env, daCtx }) { + const parsed = parseCommentsPath(daCtx.path); + if (!parsed) return errResponse(400, 'invalid_path'); + + const { rest } = parsed; + if (rest.length === 2 && rest[0] === 'threads') { + return deleteThread({ env, daCtx, parsed }); + } + if (rest.length === 4 && rest[0] === 'threads' && rest[2] === 'replies') { + return deleteReply({ env, daCtx, parsed }); + } + return errResponse(404, 'unknown_endpoint'); +} diff --git a/src/storage/object/comments.js b/src/storage/object/comments.js new file mode 100644 index 00000000..4b9cc8fb --- /dev/null +++ b/src/storage/object/comments.js @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import getS3Config from '../utils/config.js'; +import { ifMatch, ifNoneMatch } from '../utils/version.js'; + +const MAX_ATTEMPTS = 3; +const EMPTY_STATE = () => ({ version: 1, threads: {} }); + +export async function readCommentsFile(env, org, key) { + const client = new S3Client(getS3Config(env)); + try { + const resp = await client.send(new GetObjectCommand({ + Bucket: env.AEM_BUCKET_NAME, + Key: `${org}/${key}`, + })); + const text = await resp.Body.transformToString(); + return { state: JSON.parse(text), etag: resp.ETag ?? null }; + } catch (err) { + if (err.$metadata?.httpStatusCode === 404) { + return { state: EMPTY_STATE(), etag: null }; + } + throw err; + } +} + +export async function writeCommentsFile(env, org, key, state, etag) { + const config = getS3Config(env); + // First write: use If-None-Match: * to ensure the file didn't pop into existence + // after our GET returned 404. Subsequent writes: If-Match the etag we saw. + const client = etag ? ifMatch(config, etag) : ifNoneMatch(config, '*'); + try { + const resp = await client.send(new PutObjectCommand({ + Bucket: env.AEM_BUCKET_NAME, + Key: `${org}/${key}`, + Body: JSON.stringify(state), + ContentType: 'application/json', + })); + return { ok: true, etag: resp.ETag ?? null }; + } catch (err) { + if (err.$metadata?.httpStatusCode === 412) { + return { ok: false, conflict: true }; + } + throw err; + } +} + +/** + * Run `mutate(state)` against the latest server snapshot under If-Match. If the + * write loses a precondition race, refetch and retry. Up to MAX_ATTEMPTS. + * + * `mutate(state)` may return either: + * - a plain value (e.g. `{ id: ... }`) -> success, used as the wrapper's result. + * - `{ error: 'code', status: 4xx }` -> short-circuit; no write happens; this + * is returned as the result and the caller maps it to a response. + * + * Wrapper return: + * - `{ ok: true, result }` on success. + * - `{ ok: false, error: 'code', status: 4xx }` if mutate short-circuited. + * - `{ ok: false, error: 'conflict_exhausted', status: 409 }` if all + * attempts lost the precondition race. + */ +export async function atomicMutation(env, org, key, mutate) { + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + const { state, etag } = await readCommentsFile(env, org, key); + // eslint-disable-next-line no-await-in-loop + const result = await mutate(state); + if (result && result.error) return { ok: false, ...result }; + // eslint-disable-next-line no-await-in-loop + const writeResp = await writeCommentsFile(env, org, key, state, etag); + if (writeResp.ok) return { ok: true, result }; + // Conflict: loop and retry. + } + return { ok: false, error: 'conflict_exhausted', status: 409 }; +} diff --git a/src/utils/auth.js b/src/utils/auth.js index fffc3d14..052be681 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -58,6 +58,7 @@ export async function setUser(userId, expiration, reqHeaders, env) { const value = JSON.stringify({ email: json.email, ident: json.userId, + name: json.displayName, orgs, }); diff --git a/test/handlers/delete.test.js b/test/handlers/delete.test.js new file mode 100644 index 00000000..2f9dd11d --- /dev/null +++ b/test/handlers/delete.test.js @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import assert from 'node:assert'; +import esmock from 'esmock'; + +describe('DELETE Handler', () => { + it('dispatches /comments to deleteComments', async () => { + const calls = []; + const deleteHandler = (await esmock('../../src/handlers/delete.js', { + '../../src/routes/comments.js': { + deleteComments: async (args) => { + calls.push(args); + return { status: 204 }; + }, + }, + '../../src/routes/source.js': { deleteSource: async () => ({ status: 200 }) }, + })).default; + + const resp = await deleteHandler({ + req: {}, + env: {}, + daCtx: { path: '/comments/myorg/mysite/docid123/threads/t1' }, + }); + assert.strictEqual(resp.status, 204); + assert.strictEqual(calls.length, 1); + }); + + it('dispatches /source to deleteSource', async () => { + const calls = []; + const deleteHandler = (await esmock('../../src/handlers/delete.js', { + '../../src/routes/source.js': { + deleteSource: async (args) => { + calls.push(args); + return { status: 204 }; + }, + }, + '../../src/routes/comments.js': { deleteComments: async () => ({ status: 200 }) }, + })).default; + + const resp = await deleteHandler({ + req: {}, + env: {}, + daCtx: { path: '/source/myorg/myfile.html' }, + }); + assert.strictEqual(resp.status, 204); + assert.strictEqual(calls.length, 1); + }); +}); diff --git a/test/handlers/post.test.js b/test/handlers/post.test.js index ac4d95c1..409fa1db 100644 --- a/test/handlers/post.test.js +++ b/test/handlers/post.test.js @@ -62,6 +62,33 @@ describe('Post Route', () => { assert.strictEqual(mediaCalled[0].daCtx, daCtx); }); + it('dispatches /comments to postComments', async () => { + const calls = []; + const postHandlerMocked = (await esmock('../../src/handlers/post.js', { + '../../src/routes/comments.js': { + postComments: async (args) => { + calls.push(args); + return { status: 201 }; + }, + }, + '../../src/routes/source.js': { postSource: async () => ({ status: 200 }) }, + '../../src/routes/config.js': { postConfig: async () => ({ status: 200 }) }, + '../../src/routes/version.js': { postVersionSource: async () => ({ status: 200 }) }, + '../../src/routes/copy.js': { default: async () => ({ status: 200 }) }, + '../../src/routes/move.js': { default: async () => ({ status: 200 }) }, + '../../src/routes/logout.js': { default: async () => ({ status: 200 }) }, + '../../src/routes/media.js': { default: async () => ({ status: 200 }) }, + })).default; + + const resp = await postHandlerMocked({ + req: {}, + env: {}, + daCtx: { path: '/comments/myorg/mysite/docid123/threads' }, + }); + assert.strictEqual(resp.status, 201); + assert.strictEqual(calls.length, 1); + }); + it('Test unknown route returns undefined', async () => { const req = { method: 'POST' }; const env = {}; diff --git a/test/routes/comments.test.js b/test/routes/comments.test.js new file mode 100644 index 00000000..3ac5614b --- /dev/null +++ b/test/routes/comments.test.js @@ -0,0 +1,697 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import assert from 'node:assert'; +import esmock from 'esmock'; + +function makeDaCtx(overrides = {}) { + return { + path: '/comments/myorg/mysite/docid/threads', + org: 'myorg', + method: 'POST', + users: [{ email: 'alice@example.com', ident: 'alice@example.com', name: 'Alice Example' }], + aclCtx: { + // Empty pathLookup → hasPermission returns true (see auth.js). + pathLookup: new Map(), + actionSet: new Set(['read', 'write']), + }, + key: 'mysite/docid/threads', + ...overrides, + }; +} + +function makeReq(body) { + return { + json: async () => body, + url: 'http://localhost/comments/myorg/mysite/docid/threads', + }; +} + +describe('Comments Routes', () => { + describe('addThread', () => { + it('creates a thread with server-derived author and returns 201', async () => { + let captured; + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: {} }; + const result = await mutate(state); + captured = { state, result }; + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + + const req = makeReq({ + id: 't1', + anchor: { + anchorFrom: [1], anchorTo: [2], anchorType: 'text', anchorText: 'hi', + }, + body: 'hello', + createdAt: 1234, + }); + const daCtx = makeDaCtx(); + const resp = await postComments({ req, env: {}, daCtx }); + assert.strictEqual(resp.status, 201); + const responseBody = JSON.parse(resp.body); + assert.strictEqual(responseBody.id, 't1'); + + assert.strictEqual(captured.state.threads.t1.body, 'hello'); + assert.deepStrictEqual(captured.state.threads.t1.author, { id: 'alice@example.com', name: 'Alice Example', email: 'alice@example.com' }); + assert.strictEqual(captured.state.threads.t1.resolved, false); + assert.deepStrictEqual(captured.state.threads.t1.replies, []); + }); + + it('ignores client-sent author and uses IMS-derived identity', async () => { + let captured; + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: {} }; + const result = await mutate(state); + captured = state.threads.t1?.author; + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + + const req = makeReq({ + id: 't1', + anchor: { + anchorFrom: [1], anchorTo: [2], anchorType: 'text', anchorText: 'hi', + }, + body: 'hello', + createdAt: 1234, + author: { id: 'spoofed@example.com', name: 'Mallory' }, + }); + await postComments({ req, env: {}, daCtx: makeDaCtx() }); + assert.strictEqual(captured.id, 'alice@example.com'); + assert.notStrictEqual(captured.name, 'Mallory'); + }); + + it('returns 409 thread_exists when id is already taken', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: { t1: { id: 't1' } } }; + const result = await mutate(state); + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await postComments({ + req: makeReq({ + id: 't1', + anchor: { + anchorFrom: [1], anchorTo: [2], anchorType: 'text', anchorText: 'hi', + }, + body: 'x', + createdAt: 1, + }), + env: {}, + daCtx: makeDaCtx(), + }); + assert.strictEqual(resp.status, 409); + assert.strictEqual(JSON.parse(resp.body).error, 'thread_exists'); + }); + + it('returns 400 invalid_body when required fields are missing', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await postComments({ + req: makeReq({ id: 't1', body: 'x' }), // missing anchor + createdAt + env: {}, + daCtx: makeDaCtx(), + }); + assert.strictEqual(resp.status, 400); + assert.strictEqual(JSON.parse(resp.body).error, 'invalid_body'); + }); + + it('returns 401 for anonymous users', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await postComments({ + req: makeReq({ + id: 't1', + anchor: { + anchorFrom: [1], anchorTo: [2], anchorType: 'text', anchorText: 'hi', + }, + body: 'x', + createdAt: 1, + }), + env: {}, + daCtx: makeDaCtx({ users: [{ email: 'anonymous' }] }), + }); + assert.strictEqual(resp.status, 401); + }); + + it('returns 400 invalid_body for whitespace-only body', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await postComments({ + req: makeReq({ + id: 't1', + anchor: { + anchorFrom: [1], anchorTo: [2], anchorType: 'text', anchorText: 'hi', + }, + body: ' ', + createdAt: 1, + }), + env: {}, + daCtx: makeDaCtx(), + }); + assert.strictEqual(resp.status, 400); + assert.strictEqual(JSON.parse(resp.body).error, 'invalid_body'); + }); + + it('returns 400 invalid_body when body exceeds 10 KB', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const tooLong = 'a'.repeat(10 * 1024 + 1); + const resp = await postComments({ + req: makeReq({ + id: 't1', + anchor: { + anchorFrom: [1], anchorTo: [2], anchorType: 'text', anchorText: 'hi', + }, + body: tooLong, + createdAt: 1, + }), + env: {}, + daCtx: makeDaCtx(), + }); + assert.strictEqual(resp.status, 400); + assert.strictEqual(JSON.parse(resp.body).error, 'invalid_body'); + }); + + it('returns 403 forbidden when user lacks write permission', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + '../../src/utils/auth.js': { + hasPermission: () => false, + }, + }); + const resp = await postComments({ + req: makeReq({ + id: 't1', + anchor: { + anchorFrom: [1], anchorTo: [2], anchorType: 'text', anchorText: 'hi', + }, + body: 'hello', + createdAt: 1, + }), + env: {}, + daCtx: makeDaCtx(), + }); + assert.strictEqual(resp.status, 403); + assert.strictEqual(JSON.parse(resp.body).error, 'forbidden'); + }); + }); + + describe('addReply', () => { + function makeReplyReq(body, threadId = 't1') { + return { + json: async () => body, + url: `http://localhost/comments/myorg/mysite/docid/threads/${threadId}/replies`, + }; + } + + function makeReplyCtx(overrides = {}) { + return makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1/replies', + key: 'mysite/docid/threads/t1/replies', + ...overrides, + }); + } + + it('appends a reply to an existing thread with server-derived author', async () => { + let captured; + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: { t1: { id: 't1', replies: [] } } }; + const result = await mutate(state); + captured = state.threads.t1.replies; + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await postComments({ + req: makeReplyReq({ id: 'r1', body: 'hi', createdAt: 1234 }), + env: {}, + daCtx: makeReplyCtx(), + }); + assert.strictEqual(resp.status, 201); + assert.strictEqual(JSON.parse(resp.body).id, 'r1'); + assert.strictEqual(captured.length, 1); + assert.strictEqual(captured[0].body, 'hi'); + assert.deepStrictEqual(captured[0].author, { id: 'alice@example.com', name: 'Alice Example', email: 'alice@example.com' }); + }); + + it('returns 404 thread_not_found when the thread does not exist', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: {} }; + const result = await mutate(state); + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await postComments({ + req: makeReplyReq({ id: 'r1', body: 'hi', createdAt: 1234 }), + env: {}, + daCtx: makeReplyCtx(), + }); + assert.strictEqual(resp.status, 404); + assert.strictEqual(JSON.parse(resp.body).error, 'thread_not_found'); + }); + + it('returns 409 reply_exists for duplicate reply id', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: { t1: { id: 't1', replies: [{ id: 'r1' }] } } }; + const result = await mutate(state); + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await postComments({ + req: makeReplyReq({ id: 'r1', body: 'hi', createdAt: 1234 }), + env: {}, + daCtx: makeReplyCtx(), + }); + assert.strictEqual(resp.status, 409); + assert.strictEqual(JSON.parse(resp.body).error, 'reply_exists'); + }); + + it('returns 400 invalid_body when required fields are missing', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await postComments({ + req: makeReplyReq({ id: 'r1' }), + env: {}, + daCtx: makeReplyCtx(), + }); + assert.strictEqual(resp.status, 400); + assert.strictEqual(JSON.parse(resp.body).error, 'invalid_body'); + }); + + it('returns 401 for anonymous users', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await postComments({ + req: makeReplyReq({ id: 'r1', body: 'hi', createdAt: 1 }), + env: {}, + daCtx: makeReplyCtx({ users: [{ email: 'anonymous' }] }), + }); + assert.strictEqual(resp.status, 401); + }); + + it('returns 403 when user lacks write permission', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + '../../src/utils/auth.js': { + hasPermission: () => false, + }, + }); + const resp = await postComments({ + req: makeReplyReq({ id: 'r1', body: 'hi', createdAt: 1 }), + env: {}, + daCtx: makeReplyCtx(), + }); + assert.strictEqual(resp.status, 403); + }); + + it('returns 400 invalid_body for whitespace-only body', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await postComments({ + req: makeReplyReq({ id: 'r1', body: ' ', createdAt: 1 }), + env: {}, + daCtx: makeReplyCtx(), + }); + assert.strictEqual(resp.status, 400); + assert.strictEqual(JSON.parse(resp.body).error, 'invalid_body'); + }); + }); + + describe('resolveThread / unresolveThread', () => { + it('resolveThread sets resolved fields with server-derived actor', async () => { + let captured; + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: { t1: { id: 't1', resolved: false, replies: [] } } }; + const result = await mutate(state); + captured = state.threads.t1; + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await postComments({ + req: { json: async () => ({}) }, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1/resolve', + key: 'mysite/docid/threads/t1/resolve', + }), + }); + assert.strictEqual(resp.status, 200); + assert.strictEqual(captured.resolved, true); + assert.deepStrictEqual(captured.resolvedBy, { id: 'alice@example.com', name: 'Alice Example', email: 'alice@example.com' }); + assert.ok(Number.isFinite(captured.resolvedAt)); + assert.strictEqual(captured.reopenedBy, null); + assert.strictEqual(captured.reopenedAt, null); + }); + + it('unresolveThread clears resolved fields and sets reopen', async () => { + let captured; + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { + version: 1, + threads: { + t1: { + id: 't1', + resolved: true, + resolvedBy: { id: 'x', name: 'X' }, + resolvedAt: 100, + reopenedBy: null, + reopenedAt: null, + replies: [], + }, + }, + }; + const result = await mutate(state); + captured = state.threads.t1; + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await postComments({ + req: { json: async () => ({}) }, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1/unresolve', + key: 'mysite/docid/threads/t1/unresolve', + }), + }); + assert.strictEqual(resp.status, 200); + assert.strictEqual(captured.resolved, false); + assert.strictEqual(captured.resolvedBy, null); + assert.strictEqual(captured.resolvedAt, null); + assert.deepStrictEqual(captured.reopenedBy, { id: 'alice@example.com', name: 'Alice Example', email: 'alice@example.com' }); + assert.ok(Number.isFinite(captured.reopenedAt)); + }); + + it('resolveThread returns 404 when thread does not exist', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: {} }; + const result = await mutate(state); + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await postComments({ + req: { json: async () => ({}) }, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/missing/resolve', + key: 'mysite/docid/threads/missing/resolve', + }), + }); + assert.strictEqual(resp.status, 404); + assert.strictEqual(JSON.parse(resp.body).error, 'thread_not_found'); + }); + + it('resolveThread returns 401 for anonymous users', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await postComments({ + req: { json: async () => ({}) }, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1/resolve', + key: 'mysite/docid/threads/t1/resolve', + users: [{ email: 'anonymous' }], + }), + }); + assert.strictEqual(resp.status, 401); + }); + + it('resolveThread returns 403 when user lacks write permission', async () => { + const { postComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + '../../src/utils/auth.js': { + hasPermission: () => false, + }, + }); + const resp = await postComments({ + req: { json: async () => ({}) }, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1/resolve', + key: 'mysite/docid/threads/t1/resolve', + }), + }); + assert.strictEqual(resp.status, 403); + }); + }); + + describe('deleteThread / deleteReply', () => { + it('deleteThread removes the thread and returns 204', async () => { + let captured; + const { deleteComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { + version: 1, + threads: { + t1: { id: 't1', author: { id: 'alice@example.com', name: 'alice@example.com' }, replies: [] }, + t2: { id: 't2', author: { id: 'other', name: 'other' }, replies: [] }, + }, + }; + const result = await mutate(state); + captured = state; + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await deleteComments({ + req: {}, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1', + key: 'mysite/docid/threads/t1', + method: 'DELETE', + }), + }); + assert.strictEqual(resp.status, 204); + assert.strictEqual(captured.threads.t1, undefined); + assert.ok(captured.threads.t2); + }); + + it('deleteThread returns 404 when missing', async () => { + const { deleteComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: {} }; + const result = await mutate(state); + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await deleteComments({ + req: {}, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/missing', + key: 'mysite/docid/threads/missing', + method: 'DELETE', + }), + }); + assert.strictEqual(resp.status, 404); + assert.strictEqual(JSON.parse(resp.body).error, 'thread_not_found'); + }); + + it('deleteThread returns 401 for anonymous users', async () => { + const { deleteComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + }); + const resp = await deleteComments({ + req: {}, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1', + key: 'mysite/docid/threads/t1', + method: 'DELETE', + users: [{ email: 'anonymous' }], + }), + }); + assert.strictEqual(resp.status, 401); + }); + + it('deleteThread returns 403 when user lacks write permission', async () => { + const { deleteComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async () => { throw new Error('should not be called'); }, + }, + '../../src/utils/auth.js': { + hasPermission: () => false, + }, + }); + const resp = await deleteComments({ + req: {}, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1', + key: 'mysite/docid/threads/t1', + method: 'DELETE', + }), + }); + assert.strictEqual(resp.status, 403); + }); + + it('deleteReply removes a reply, leaves the thread', async () => { + let captured; + const { deleteComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { + version: 1, + threads: { + t1: { + id: 't1', + replies: [ + { id: 'r1', body: 'one' }, + { id: 'r2', body: 'two' }, + ], + }, + }, + }; + const result = await mutate(state); + captured = state.threads.t1; + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await deleteComments({ + req: {}, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1/replies/r1', + key: 'mysite/docid/threads/t1/replies/r1', + method: 'DELETE', + }), + }); + assert.strictEqual(resp.status, 204); + assert.strictEqual(captured.replies.length, 1); + assert.strictEqual(captured.replies[0].id, 'r2'); + }); + + it('deleteReply returns 404 when reply not present', async () => { + const { deleteComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: { t1: { id: 't1', replies: [] } } }; + const result = await mutate(state); + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await deleteComments({ + req: {}, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/t1/replies/missing', + key: 'mysite/docid/threads/t1/replies/missing', + method: 'DELETE', + }), + }); + assert.strictEqual(resp.status, 404); + assert.strictEqual(JSON.parse(resp.body).error, 'reply_not_found'); + }); + + it('deleteReply returns 404 when thread missing', async () => { + const { deleteComments } = await esmock('../../src/routes/comments.js', { + '../../src/storage/object/comments.js': { + atomicMutation: async (env, org, key, mutate) => { + const state = { version: 1, threads: {} }; + const result = await mutate(state); + if (result?.error) return { ok: false, ...result }; + return { ok: true, result }; + }, + }, + }); + const resp = await deleteComments({ + req: {}, + env: {}, + daCtx: makeDaCtx({ + path: '/comments/myorg/mysite/docid/threads/missing/replies/r1', + key: 'mysite/docid/threads/missing/replies/r1', + method: 'DELETE', + }), + }); + assert.strictEqual(resp.status, 404); + assert.strictEqual(JSON.parse(resp.body).error, 'thread_not_found'); + }); + }); +}); diff --git a/test/storage/object/comments.test.js b/test/storage/object/comments.test.js new file mode 100644 index 00000000..f929ccfc --- /dev/null +++ b/test/storage/object/comments.test.js @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import assert from 'node:assert'; +import esmock from 'esmock'; + +describe('storage/object/comments', () => { + describe('readCommentsFile', () => { + it('returns the parsed state and etag on 200', async () => { + const fakeBody = JSON.stringify({ version: 1, threads: { t1: { id: 't1' } } }); + const fakeResp = { + Body: { transformToString: async () => fakeBody }, + ETag: '"abc123"', + }; + function FakeS3Client() { + this.send = async () => fakeResp; + } + const { readCommentsFile } = await esmock('../../../src/storage/object/comments.js', { + '@aws-sdk/client-s3': { + S3Client: FakeS3Client, + GetObjectCommand: function GetObjectCommand() {}, + PutObjectCommand: function PutObjectCommand() {}, + }, + }); + const result = await readCommentsFile({ AEM_BUCKET_NAME: 'b' }, 'org', 'site/.da/comments/d.json'); + assert.deepStrictEqual(result.state, { version: 1, threads: { t1: { id: 't1' } } }); + assert.strictEqual(result.etag, '"abc123"'); + }); + + it('returns empty state and null etag on 404', async () => { + function FakeS3Client() { + this.send = async () => { + const err = new Error('not found'); + err.$metadata = { httpStatusCode: 404 }; + throw err; + }; + } + const { readCommentsFile } = await esmock('../../../src/storage/object/comments.js', { + '@aws-sdk/client-s3': { + S3Client: FakeS3Client, + GetObjectCommand: function GetObjectCommand() {}, + PutObjectCommand: function PutObjectCommand() {}, + }, + }); + const result = await readCommentsFile({ AEM_BUCKET_NAME: 'b' }, 'org', 'site/.da/comments/d.json'); + assert.deepStrictEqual(result.state, { version: 1, threads: {} }); + assert.strictEqual(result.etag, null); + }); + }); + + describe('atomicMutation', () => { + function buildFakeClientCtor(impl) { + return function FakeS3Client() { + this.send = impl; + this.middlewareStack = { add: () => {} }; + }; + } + + async function loadModule(FakeS3Client) { + const fakeClient = new FakeS3Client(); + return esmock('../../../src/storage/object/comments.js', { + '@aws-sdk/client-s3': { + S3Client: FakeS3Client, + GetObjectCommand: function GetObjectCommand() {}, + PutObjectCommand: function PutObjectCommand() {}, + }, + '../../../src/storage/utils/version.js': { + ifMatch: () => fakeClient, + ifNoneMatch: () => fakeClient, + }, + }); + } + + it('runs mutate once on the happy path', async () => { + let getCount = 0; + let putCount = 0; + const FakeS3Client = buildFakeClientCtor(async (cmd) => { + if (cmd.constructor.name === 'GetObjectCommand') { + getCount += 1; + return { + Body: { transformToString: async () => '{"version":1,"threads":{}}' }, + ETag: `"v${getCount}"`, + }; + } + putCount += 1; + return { ETag: '"newetag"' }; + }); + const { atomicMutation } = await loadModule(FakeS3Client); + + const result = await atomicMutation({ AEM_BUCKET_NAME: 'b' }, 'org', 'k', (state) => { + // eslint-disable-next-line no-param-reassign + state.threads.t1 = { id: 't1' }; + return { id: 't1' }; + }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.result, { id: 't1' }); + assert.strictEqual(getCount, 1); + assert.strictEqual(putCount, 1); + }); + + it('retries on a 412 conflict and succeeds on second attempt', async () => { + let getCount = 0; + let putCount = 0; + const FakeS3Client = buildFakeClientCtor(async (cmd) => { + if (cmd.constructor.name === 'GetObjectCommand') { + getCount += 1; + return { + Body: { transformToString: async () => '{"version":1,"threads":{}}' }, + ETag: `"v${getCount}"`, + }; + } + putCount += 1; + if (putCount === 1) { + const err = new Error('precondition failed'); + err.$metadata = { httpStatusCode: 412 }; + throw err; + } + return { ETag: '"newetag"' }; + }); + const { atomicMutation } = await loadModule(FakeS3Client); + + const result = await atomicMutation({ AEM_BUCKET_NAME: 'b' }, 'org', 'k', (state) => { + // eslint-disable-next-line no-param-reassign + state.threads.t1 = { id: 't1' }; + return { id: 't1' }; + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(getCount, 2); + assert.strictEqual(putCount, 2); + }); + + it('returns conflict_exhausted after MAX_ATTEMPTS losses', async () => { + const FakeS3Client = buildFakeClientCtor(async (cmd) => { + if (cmd.constructor.name === 'GetObjectCommand') { + return { + Body: { transformToString: async () => '{"version":1,"threads":{}}' }, + ETag: '"v1"', + }; + } + const err = new Error('precondition failed'); + err.$metadata = { httpStatusCode: 412 }; + throw err; + }); + const { atomicMutation } = await loadModule(FakeS3Client); + + const result = await atomicMutation({ AEM_BUCKET_NAME: 'b' }, 'org', 'k', () => ({})); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.error, 'conflict_exhausted'); + assert.strictEqual(result.status, 409); + }); + + it('short-circuits when mutate returns {error}', async () => { + let putCount = 0; + const FakeS3Client = buildFakeClientCtor(async (cmd) => { + if (cmd.constructor.name === 'GetObjectCommand') { + return { + Body: { transformToString: async () => '{"version":1,"threads":{}}' }, + ETag: '"v1"', + }; + } + putCount += 1; + return { ETag: '"newetag"' }; + }); + const { atomicMutation } = await loadModule(FakeS3Client); + + const result = await atomicMutation({ AEM_BUCKET_NAME: 'b' }, 'org', 'k', () => ({ error: 'thread_exists', status: 409 })); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.error, 'thread_exists'); + assert.strictEqual(result.status, 409); + assert.strictEqual(putCount, 0); // No PUT should happen. + }); + }); +});