From 5485f57e0292421e2279f68c6582b5a5cf35ab3b Mon Sep 17 00:00:00 2001 From: Thomas Durieux <5577568+tdurieux@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:16:57 +0000 Subject: [PATCH] Retain session-bound artifact owner consent under current authority --- .github/workflows/ci.yml | 38 ++ docs/review-owner-consent.md | 23 + .../anonymizedRepositories.schema.ts | 1 + src/core/model/users/users.schema.ts | 1 + src/server/service/review-owner-consent.ts | 326 +++++++++++ test/review-owner-consent.test.js | 509 ++++++++++++++++++ 6 files changed, 898 insertions(+) create mode 100644 docs/review-owner-consent.md create mode 100644 src/server/service/review-owner-consent.ts create mode 100644 test/review-owner-consent.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46a2a9d..be4c47a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,41 @@ jobs: - run: npm ci - run: npm run build:ui - run: npm test + + review-consent: + name: Review consent transactions + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx tsc --noEmit + - name: Start disposable MongoDB replica set + run: | + docker run -d --name review-consent-test --network host --memory=512m --cpus=1 --tmpfs /data/db:rw,noexec,nosuid,size=256m mongo:7.0@sha256:0e145625e78b94224d16222ff2609c4621ff6e2c390300e4e6bf698305596792 mongod --bind_ip 127.0.0.1 --port 28743 --replSet review-consent-test --oplogSize 16 + for attempt in $(seq 1 40); do + if docker exec review-consent-test mongosh --quiet --port 28743 --eval 'db.adminCommand({ping:1})' > /dev/null 2>&1; then break; fi + sleep 1 + done + docker exec review-consent-test mongosh --quiet --port 28743 --eval 'rs.initiate({_id:"review-consent-test",members:[{_id:0,host:"127.0.0.1:28743"}]})' + for attempt in $(seq 1 40); do + if docker exec review-consent-test mongosh --quiet --port 28743 --eval 'if (!db.hello().isWritablePrimary) quit(1)' > /dev/null 2>&1; then exit 0; fi + sleep 1 + done + exit 1 + - name: Verify consent authority, concurrency and replay cost + env: + TEST_REVIEW_CONSENT_MONGO: mongodb://127.0.0.1:28743/?replicaSet=review-consent-test + TEST_REVIEW_CONSENT_PERF_REPORT: review-consent-perf.json + run: npx mocha test/review-owner-consent.test.js + - uses: actions/upload-artifact@v4 + with: + name: review-consent-performance + path: review-consent-perf.json + - name: Remove disposable database + if: always() + run: docker rm -f review-consent-test || true diff --git a/docs/review-owner-consent.md b/docs/review-owner-consent.md new file mode 100644 index 0000000..7dc9105 --- /dev/null +++ b/docs/review-owner-consent.md @@ -0,0 +1,23 @@ +# Review owner consent backend + +`createReviewOwnerConsent` prepares a signed policy preview from the authenticated review intent consumer and records explicit access/retention acceptance in MongoDB. It is an unwired backend. Browser routes, CSRF protection, UI, completion-code exchange and activation remain unfinished. Capabilities continue to advertise no available integration features. + +The caller must provide an account ID and session ID derived from a current authenticated browser session. Never populate this principal from request JSON or treat a service credential as an owner's identity. The factory also needs the configured intent consumer, a dedicated random 32-byte signing key held outside Git, and a connected MongoDB replica set. Configure bounded server-selection and socket timeouts on that connection. Tests use MongoDB 7 with a three-second server-selection timeout and ten-second socket timeout. + +Preview checks the current account and repository before consuming the review intent, then checks them again afterward. The network request holds no MongoDB transaction or authority lock. The account must be active or have the legacy unset active status; the repository must be ready and unexpired. Ownership uses the current account ObjectId. Coauthors must match the account's current numeric GitHub ID. Legacy username-only coauthors and unrelated administrators cannot provide consent. + +The signed preview binds the saved intent and exact policy to the account, hashed session ID and repository ObjectId. Changing the account, session, repository or policy invalidates confirmation. The signing context is specific to this protocol; key rotation invalidates outstanding previews. Confirmation requires both access and retention acceptance and an explicit 32-hex request ID. Preview and replay expire with the intent or retention policy. + +Confirmation writes one immutable `review_owner_consents` record per client/intent. It records the account, repository, authority role, exact policy, request ID, confirmation time and a hash of the signed preview. It stores no raw session ID, intent token, service credential or preview ticket. Explicit retries return the same receipt only after current authority checks; another request ID or repository conflicts. No binding is created by accepting a policy. + +Transactions touch the current account and repository before writing or reading the receipt. Ownership transfer, coauthor removal and account disablement therefore serialize with confirmation. The private `reviewConsentRevision` fields are excluded from ordinary Mongoose reads. Four transactions may be active per factory. Database commands and commits have limits; transactions are not automatically retried. A conflict or uncertain commit returns a fixed `unavailable` error, and the caller must retain the same ticket and request ID for explicit recovery. + +The final binding flow must still revalidate the initiating review session and saved intent scope with the review service. This backend proves current upstream owner/coauthor consent, not continuing review-side author authority, immutable snapshots, restricted file access or preservation. Receipt retention/export and the HTTP adapter remain separate work; this change enables no provider or public route. + +## Validation + +The opt-in suite runs against a disposable MongoDB replica set. It covers literal acceptance, immutable replay, stable-ID coauthors, administrator/username rejection, ticket tampering, account/session binding, disabled accounts, ownership changes, removed coauthors, archived/expired repositories, permission changes during upstream I/O, concurrent confirmations, expiry, second-repository conflicts and authority-field privacy. A held ownership-change transaction forces a real write conflict; the explicit retry is denied and no receipt is created. + +Run `TEST_REVIEW_CONSENT_MONGO='mongodb://127.0.0.1:28743/?replicaSet=review-consent-test' npx mocha test/review-owner-consent.test.js`. Set `TEST_REVIEW_CONSENT_PERF_REPORT=/tmp/review-consent-perf.json` to include 100 durable receipt replays. The CI job starts and removes its own pinned MongoDB image and preserves the performance report. Local timings are synthetic and are not a production latency guarantee. + +Local validation passes 12 targeted cases with the benchmark enabled, 723 full-suite cases with 51 opt-in/environment-dependent cases pending, the full TypeScript check and targeted lint. The final 100-replay run measured mean 12.85 ms, median 12.82 ms and p95 14.89 ms against a one-CPU, 512 MiB disposable database. An earlier performance attempt encountered a database conflict while the privacy fixture automatically built indexes; the final fixture disables automatic schema/index creation and creates its collections explicitly. diff --git a/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts b/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts index e7ac0c3..5be6492 100644 --- a/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts +++ b/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts @@ -2,6 +2,7 @@ import { repositoryAccessSchema } from "../repository-access.schema"; import { Schema } from "mongoose"; const AnonymizedRepositorySchema = new Schema({ + reviewConsentRevision: { type: Number, select: false }, repoId: { type: String, index: { unique: true, collation: { locale: "en", strength: 2 } }, diff --git a/src/core/model/users/users.schema.ts b/src/core/model/users/users.schema.ts index be84b78..cc852e0 100644 --- a/src/core/model/users/users.schema.ts +++ b/src/core/model/users/users.schema.ts @@ -1,6 +1,7 @@ import { Schema } from "mongoose"; const UserSchema = new Schema({ + reviewConsentRevision: { type: Number, select: false }, accessTokens: { github: { type: String, select: false }, }, diff --git a/src/server/service/review-owner-consent.ts b/src/server/service/review-owner-consent.ts new file mode 100644 index 0000000..4897fe8 --- /dev/null +++ b/src/server/service/review-owner-consent.ts @@ -0,0 +1,326 @@ +import { createHash, createHmac, timingSafeEqual } from "crypto"; +import { Connection, ClientSession, Types } from "mongoose"; +import { + ConsumedReviewIntent, + IntentConsumption, + createReviewIntentConsumer, +} from "./review-intent-client"; + +type Principal = { accountId: string; sessionId: string }; +type Authority = { + accountId: string; + repositoryId: string; + repositoryName: string; + role: "owner" | "coauthor"; +}; +type Quote = { + version: 1; + accountId: string; + sessionHash: string; + repositoryId: string; + intent: ConsumedReviewIntent; +}; +export type ConsentReceipt = Readonly<{ + clientId: string; + intentId: string; + accountId: string; + repositoryId: string; + role: "owner" | "coauthor"; + policy: ConsumedReviewIntent["policy"]; + requestId: string; + confirmedAt: string; +}>; +type SavedConsent = { + _id: string; + accountId: string; + repositoryId: string; + requestId: string; + ticketHash: string; + receipt: ConsentReceipt; +}; +export class ReviewConsentError extends Error { + constructor( + public readonly kind: + "invalid" | "forbidden" | "expired" | "conflict" | "unavailable", + ) { + super("Review owner consent " + kind); + this.name = "ReviewConsentError"; + } +} +const deny = (kind: ReviewConsentError["kind"]): never => { + throw new ReviewConsentError(kind); +}; +const hash = (value: string) => + createHash("sha256").update(value).digest("hex"); +const objectId = (value: unknown): value is string => + typeof value === "string" && /^[a-f0-9]{24}$/.test(value); +function principal(value: Principal): { + accountId: string; + sessionHash: string; +} { + if ( + !value || + !objectId(value.accountId) || + typeof value.sessionId !== "string" || + !/^[A-Za-z0-9_-]{24,256}$/.test(value.sessionId) + ) + deny("invalid"); + return { accountId: value.accountId, sessionHash: hash(value.sessionId) }; +} + +/** Server-side consent storage. Principals must come from current authenticated + * browser sessions, never request JSON. A future HTTP adapter must apply its + * own same-origin/CSRF checks. No route or provider is enabled by this factory. */ +export function createReviewOwnerConsent( + connection: Connection, + consumer: ReturnType, + signingKey: Buffer, +) { + if (!Buffer.isBuffer(signingKey) || signingKey.length !== 32) deny("invalid"); + const key = Buffer.from(signingKey); + let activeTransactions = 0; + const signature = (body: string) => + createHmac("sha256", key) + .update("4open.review-consent/1." + body) + .digest(); + function decode(ticket: string, who: ReturnType): Quote { + if ( + typeof ticket !== "string" || + ticket.length > 8192 || + !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/.test(ticket) + ) + deny("invalid"); + const [body, encoded] = ticket.split("."); + const supplied = Buffer.from(encoded, "base64url"); + if ( + supplied.length !== 32 || + supplied.toString("base64url") !== encoded || + !timingSafeEqual(supplied, signature(body)) + ) + deny("invalid"); + let quote: Quote; + try { + quote = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); + } catch { + return deny("invalid"); + } + if ( + quote.version !== 1 || + quote.accountId !== who.accountId || + quote.sessionHash !== who.sessionHash || + !objectId(quote.repositoryId) + ) + deny("forbidden"); + if ( + Date.parse(quote.intent.expiresAt) <= Date.now() || + Date.parse(quote.intent.policy.retainUntil) <= Date.now() + ) + deny("expired"); + return quote; + } + async function transaction( + who: ReturnType, + repository: { repoId: string } | { _id: Types.ObjectId }, + work: (authority: Authority, session: ClientSession) => Promise, + ): Promise { + if (connection.readyState !== 1 || activeTransactions >= 4) + return deny("unavailable"); + activeTransactions++; + const session = await connection.startSession().catch(() => { + activeTransactions--; + return deny("unavailable"); + }); + try { + session.startTransaction({ + readConcern: { level: "snapshot" }, + writeConcern: { w: "majority", wtimeoutMS: 3000 }, + maxCommitTimeMS: 5000, + }); + // Touch the authority documents in the same transaction as the receipt. + // Concurrent disablement, ownership changes and coauthor removal then + // conflict with this transaction instead of producing a stale consent. + const user = ( + await connection.db.collection("users").findOneAndUpdate( + { + _id: new Types.ObjectId(who.accountId), + $or: [{ status: "active" }, { status: { $exists: false } }], + }, + { $inc: { reviewConsentRevision: 1 } }, + { + session, + returnDocument: "after", + maxTimeMS: 3000, + projection: { externalIDs: 1 }, + }, + ) + ).value; + if (!user) return deny("forbidden"); + const githubId = user.externalIDs?.github; + const roles: Record[] = [ + { owner: new Types.ObjectId(who.accountId) }, + ]; + if (typeof githubId === "string" && /^[1-9][0-9]{0,19}$/.test(githubId)) + roles.push({ "coauthors.githubId": githubId }); + const repo = ( + await connection.db + .collection("anonymizedrepositories") + .findOneAndUpdate( + { ...repository, status: "ready", $or: roles }, + { $inc: { reviewConsentRevision: 1 } }, + { + session, + returnDocument: "after", + maxTimeMS: 3000, + projection: { repoId: 1, owner: 1, options: 1 }, + }, + ) + ).value; + if (!repo) return deny("forbidden"); + if ( + repo.options?.expirationMode !== "never" && + repo.options?.expirationDate && + (!Number.isFinite(new Date(repo.options.expirationDate).getTime()) || + new Date(repo.options.expirationDate).getTime() <= Date.now()) + ) + return deny("expired"); + const authority: Authority = { + accountId: who.accountId, + repositoryId: String(repo._id), + repositoryName: repo.repoId, + role: String(repo.owner) === who.accountId ? "owner" : "coauthor", + }; + const result = await work(authority, session); + await session.commitTransaction(); + return result; + } catch (error) { + if (session.inTransaction()) + await session.abortTransaction().catch(() => undefined); + if (error instanceof ReviewConsentError) throw error; + return deny("unavailable"); + } finally { + activeTransactions--; + await session.endSession().catch(() => undefined); + } + } + return Object.freeze({ + async preview( + actor: Principal, + repositoryName: string, + input: IntentConsumption, + signal?: AbortSignal, + ) { + const who = principal(actor); + const command = { ...input }; + if ( + typeof repositoryName !== "string" || + !/^[A-Za-z0-9_-]{3,128}$/.test(repositoryName) + ) + return deny("invalid"); + const before = await transaction( + who, + { repoId: repositoryName }, + async (authority) => authority, + ); + // The provider call holds no database transaction or authority lock. + const intent = await consumer.consume(command, signal); + const current = await transaction( + who, + { _id: new Types.ObjectId(before.repositoryId) }, + async (authority) => authority, + ); + if ( + signal?.aborted || + Date.parse(intent.expiresAt) <= Date.now() || + Date.parse(intent.policy.retainUntil) <= Date.now() + ) + return deny("expired"); + const quote: Quote = { + version: 1, + accountId: who.accountId, + sessionHash: who.sessionHash, + repositoryId: current.repositoryId, + intent, + }; + const body = Buffer.from(JSON.stringify(quote)).toString("base64url"); + return Object.freeze({ + ticket: body + "." + signature(body).toString("base64url"), + repositoryName: current.repositoryName, + policy: intent.policy, + expiresAt: intent.expiresAt, + }); + }, + async confirm( + actor: Principal, + ticket: string, + input: { + requestId: string; + acceptAccess: boolean; + acceptRetention: boolean; + }, + ): Promise { + const who = principal(actor); + if ( + !input || + Object.keys(input).sort().join(",") !== + "acceptAccess,acceptRetention,requestId" || + input.acceptAccess !== true || + input.acceptRetention !== true || + typeof input.requestId !== "string" || + !/^[a-f0-9]{32}$/.test(input.requestId) + ) + return deny("invalid"); + const quote = decode(ticket, who), + requestId = input.requestId; + return transaction( + who, + { _id: new Types.ObjectId(quote.repositoryId) }, + async (authority, session) => { + decode(ticket, who); // Recheck expiry after waiting for authority locks. + const collection = connection.db.collection( + "review_owner_consents", + ); + const id = quote.intent.clientId + ":" + quote.intent.intentId; + const ticketHash = hash(ticket); + // A string _id binds each intent to at most one accepted repository and + // session. No raw session ID, intent token or service key is persisted. + const existing = await collection.findOne( + { _id: id }, + { session, maxTimeMS: 3000 }, + ); + if (existing) { + if ( + existing.ticketHash !== ticketHash || + existing.requestId !== requestId || + existing.accountId !== who.accountId || + existing.repositoryId !== authority.repositoryId + ) + return deny("conflict"); + return Object.freeze(existing.receipt as ConsentReceipt); + } + const receipt: ConsentReceipt = Object.freeze({ + clientId: quote.intent.clientId, + intentId: quote.intent.intentId, + accountId: who.accountId, + repositoryId: authority.repositoryId, + role: authority.role, + policy: quote.intent.policy, + requestId, + confirmedAt: new Date().toISOString(), + }); + await collection.insertOne( + { + _id: id, + accountId: who.accountId, + repositoryId: authority.repositoryId, + requestId, + ticketHash, + receipt, + }, + { session, maxTimeMS: 3000 }, + ); + return receipt; + }, + ); + }, + }); +} diff --git a/test/review-owner-consent.test.js b/test/review-owner-consent.test.js new file mode 100644 index 0000000..eb45004 --- /dev/null +++ b/test/review-owner-consent.test.js @@ -0,0 +1,509 @@ +require("ts-node/register/transpile-only"); +const { expect } = require("chai"); +const mongoose = require("mongoose"); +const { randomBytes } = require("crypto"); +const process = require("process"); +const { + createReviewOwnerConsent, +} = require("../src/server/service/review-owner-consent"); +const actorId = new mongoose.Types.ObjectId(); +const coauthorId = new mongoose.Types.ObjectId(); +const otherId = new mongoose.Types.ObjectId(); +const repoId = new mongoose.Types.ObjectId(); +const actor = (accountId = actorId, sessionId = "s".repeat(32)) => ({ + accountId: String(accountId), + sessionId, +}); +const request = () => ({ + contract: "4open.artifacts/1", + clientId: "1".repeat(32), + intentId: "2".repeat(32), + token: "3".repeat(64), + requestId: "4".repeat(32), +}); +const intent = () => ({ + contract: "4open.artifacts/1", + clientId: "1".repeat(32), + intentId: "2".repeat(32), + submissionRef: "5".repeat(32), + callbackId: "6".repeat(32), + entitlementId: "7".repeat(32), + expiresAt: new Date(Date.now() + 120000) + .toISOString() + .replace(/\.\d{3}Z$/, "Z"), + policy: { + version: 1, + access: "restricted-review", + retainUntil: "2099-01-01T00:00:00Z", + }, +}); +const confirmation = () => ({ + requestId: "8".repeat(32), + acceptAccess: true, + acceptRetention: true, +}); +async function rejected(promise, kind) { + try { + await promise; + expect.fail("operation unexpectedly succeeded"); + } catch (error) { + expect(error.kind).to.equal(kind); + expect(error.message).to.equal("Review owner consent " + kind); + } +} + +describe("review owner consent transactions", function () { + this.timeout(15000); + let connection, store, calls, upstream; + const key = randomBytes(32); + before(async function () { + if (!process.env.TEST_REVIEW_CONSENT_MONGO) this.skip(); + connection = await mongoose + .createConnection(process.env.TEST_REVIEW_CONSENT_MONGO, { + dbName: "review_consent_test_" + randomBytes(8).toString("hex"), + serverSelectionTimeoutMS: 3000, + socketTimeoutMS: 10000, + monitorCommands: true, + autoIndex: false, + autoCreate: false, + maxPoolSize: 10, + }) + .asPromise(); + await connection.db.createCollection("review_owner_consents"); + }); + after(async () => { + if (connection) { + await connection.dropDatabase(); + await connection.close(); + } + }); + beforeEach(async () => { + await Promise.all( + ["users", "anonymizedrepositories", "review_owner_consents"].map((name) => + connection.db.collection(name).deleteMany({}), + ), + ); + await connection.db.collection("users").insertMany([ + { + _id: actorId, + username: "owner", + externalIDs: { github: "101" }, + status: "active", + }, + { + _id: coauthorId, + username: "coauthor", + externalIDs: { github: "102" }, + status: "active", + }, + { + _id: otherId, + username: "other", + externalIDs: { github: "103" }, + isAdmin: true, + status: "active", + }, + ]); + await connection.db.collection("anonymizedrepositories").insertOne({ + _id: repoId, + repoId: "synthetic-repository", + owner: actorId, + status: "ready", + coauthors: [{ username: "coauthor", githubId: "102" }], + options: { expirationMode: "never" }, + }); + calls = 0; + upstream = async () => intent(); + store = createReviewOwnerConsent( + connection, + { + consume: async (...args) => { + calls++; + return upstream(...args); + }, + }, + key, + ); + }); + it("requires explicit policy acceptance and retains one immutable receipt on retries", async () => { + const preview = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + expect(preview.policy).to.deep.equal(intent().policy); + expect(preview.repositoryName).to.equal("synthetic-repository"); + expect(calls).to.equal(1); + for (const patch of [ + { acceptAccess: false }, + { acceptRetention: false }, + { acceptAccess: "true" }, + { policy: {} }, + ]) + await rejected( + store.confirm(actor(), preview.ticket, { ...confirmation(), ...patch }), + "invalid", + ); + const first = await store.confirm(actor(), preview.ticket, confirmation()); + expect(first.role).to.equal("owner"); + expect(first.policy).to.deep.equal(preview.policy); + const replay = await store.confirm(actor(), preview.ticket, confirmation()); + expect(replay).to.deep.equal(first); + await rejected( + store.confirm(actor(), preview.ticket, { + ...confirmation(), + requestId: "9".repeat(32), + }), + "conflict", + ); + const rows = await connection.db + .collection("review_owner_consents") + .find({}) + .toArray(); + expect(rows).to.have.length(1); + const stored = JSON.stringify(rows); + for (const secret of [actor().sessionId, request().token, preview.ticket]) + expect(stored).not.to.include(secret); + expect(calls).to.equal(1); + }); + it("accepts a stable-ID coauthor but never an unrelated administrator or matching legacy username", async () => { + const quote = await store.preview( + actor(coauthorId), + "synthetic-repository", + request(), + ); + expect( + (await store.confirm(actor(coauthorId), quote.ticket, confirmation())) + .role, + ).to.equal("coauthor"); + await rejected( + store.preview(actor(otherId), "synthetic-repository", request()), + "forbidden", + ); + await connection.db + .collection("anonymizedrepositories") + .updateOne( + { _id: repoId }, + { $set: { coauthors: [{ username: "coauthor" }] } }, + ); + await rejected( + store.preview(actor(coauthorId), "synthetic-repository", request()), + "forbidden", + ); + expect(calls).to.equal(1); + }); + it("binds signed previews to the account, session and exact policy", async () => { + const preview = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + await rejected( + store.confirm(actor(otherId), preview.ticket, confirmation()), + "forbidden", + ); + await rejected( + store.confirm( + actor(actorId, "n".repeat(32)), + preview.ticket, + confirmation(), + ), + "forbidden", + ); + const [body, signature] = preview.ticket.split("."); + const modified = JSON.parse(Buffer.from(body, "base64url").toString()); + modified.intent.policy.access = "anonymous-link"; + await rejected( + store.confirm( + actor(), + Buffer.from(JSON.stringify(modified)).toString("base64url") + + "." + + signature, + confirmation(), + ), + "invalid", + ); + await rejected( + store.confirm(actor(), "x".repeat(8193), confirmation()), + "invalid", + ); + expect( + await connection.db.collection("review_owner_consents").countDocuments(), + ).to.equal(0); + }); + it("rechecks owner status and revocation when confirming or replaying", async () => { + const quote = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + await store.confirm(actor(), quote.ticket, confirmation()); + await connection.db + .collection("users") + .updateOne({ _id: actorId }, { $set: { status: "banned" } }); + await rejected( + store.confirm(actor(), quote.ticket, confirmation()), + "forbidden", + ); + await connection.db + .collection("users") + .updateOne({ _id: actorId }, { $set: { status: "active" } }); + await connection.db + .collection("anonymizedrepositories") + .updateOne({ _id: repoId }, { $set: { owner: otherId } }); + await rejected( + store.confirm(actor(), quote.ticket, confirmation()), + "forbidden", + ); + expect( + await connection.db.collection("review_owner_consents").countDocuments(), + ).to.equal(1); + }); + it("rejects removed coauthors and archived or expired repositories", async () => { + const quote = await store.preview( + actor(coauthorId), + "synthetic-repository", + request(), + ); + await connection.db + .collection("anonymizedrepositories") + .updateOne({ _id: repoId }, { $set: { coauthors: [] } }); + await rejected( + store.confirm(actor(coauthorId), quote.ticket, confirmation()), + "forbidden", + ); + await connection.db + .collection("anonymizedrepositories") + .updateOne({ _id: repoId }, { $set: { status: "archived" } }); + await rejected( + store.preview(actor(), "synthetic-repository", request()), + "forbidden", + ); + await connection.db.collection("anonymizedrepositories").updateOne( + { _id: repoId }, + { + $set: { + status: "ready", + options: { expirationMode: "remove", expirationDate: new Date(0) }, + }, + }, + ); + await rejected( + store.preview(actor(), "synthetic-repository", request()), + "expired", + ); + expect(calls).to.equal(1); + }); + it("does not hold database authority locks across the upstream request and checks removal afterward", async () => { + let ready, release; + const arrived = new Promise((resolve) => { + ready = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + upstream = async () => { + ready(); + await gate; + return intent(); + }; + const pending = store.preview( + actor(coauthorId), + "synthetic-repository", + request(), + ); + await arrived; + try { + await connection.db + .collection("anonymizedrepositories") + .updateOne( + { _id: repoId }, + { $set: { coauthors: [] } }, + { maxTimeMS: 1000 }, + ); + } finally { + release(); + } + await rejected(pending, "forbidden"); + expect( + await connection.db.collection("review_owner_consents").countDocuments(), + ).to.equal(0); + }); + it("keeps one receipt across concurrent confirmations and permits explicit recovery", async () => { + const quote = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + const results = await Promise.allSettled([ + store.confirm(actor(), quote.ticket, confirmation()), + store.confirm(actor(), quote.ticket, confirmation()), + ]); + expect(results.some((r) => r.status === "fulfilled")).to.equal(true); + for (const result of results) + if (result.status === "rejected") + expect(result.reason.kind).to.equal("unavailable"); + const retry = await store.confirm(actor(), quote.ticket, confirmation()); + for (const result of results) + if (result.status === "fulfilled") + expect(result.value).to.deep.equal(retry); + expect( + await connection.db.collection("review_owner_consents").countDocuments(), + ).to.equal(1); + }); + it("rejects expired previews without recording consent", async () => { + upstream = async () => ({ + ...intent(), + expiresAt: new Date(Date.now() + 2000).toISOString(), + }); + const quote = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + const { setTimeout } = require("timers/promises"); + await setTimeout(2100); + await rejected( + store.confirm(actor(), quote.ticket, confirmation()), + "expired", + ); + expect( + await connection.db.collection("review_owner_consents").countDocuments(), + ).to.equal(0); + }); + it("cannot confirm a quote for a second repository under the same intent", async () => { + const first = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + await store.confirm(actor(), first.ticket, confirmation()); + await connection.db.collection("anonymizedrepositories").insertOne({ + repoId: "second-repository", + owner: actorId, + status: "ready", + options: { expirationMode: "never" }, + }); + const second = await store.preview(actor(), "second-repository", request()); + await rejected( + store.confirm(actor(), second.ticket, confirmation()), + "conflict", + ); + expect( + await connection.db.collection("review_owner_consents").countDocuments(), + ).to.equal(1); + }); + it("conflicts with a concurrent ownership change and rejects its explicit retry", async () => { + const quote = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + const session = await connection.startSession(); + session.startTransaction(); + await connection.db + .collection("anonymizedrepositories") + .updateOne({ _id: repoId }, { $set: { owner: otherId } }, { session }); + let arrived; + const ready = new Promise((resolve) => { + arrived = resolve; + }); + const listener = (event) => { + if ( + event.commandName === "findAndModify" && + event.command.findAndModify === "anonymizedrepositories" + ) + arrived(); + }; + connection.getClient().on("commandStarted", listener); + const pending = store.confirm(actor(), quote.ticket, confirmation()).then( + () => "ok", + (error) => error.kind, + ); + try { + await ready; + await session.commitTransaction(); + expect(await pending).to.equal("unavailable"); + await rejected( + store.confirm(actor(), quote.ticket, confirmation()), + "forbidden", + ); + expect( + await connection.db + .collection("review_owner_consents") + .countDocuments(), + ).to.equal(0); + } finally { + connection.getClient().off("commandStarted", listener); + if (session.inTransaction()) await session.abortTransaction(); + await session.endSession(); + } + }); + it("keeps authority serialization fields out of ordinary user and repository reads", async () => { + await store.preview(actor(), "synthetic-repository", request()); + const users = connection.model( + "ConsentPrivacyUser", + require("../src/core/model/users/users.schema").default, + "users", + ); + const repos = connection.model( + "ConsentPrivacyRepo", + require("../src/core/model/anonymizedRepositories/anonymizedRepositories.schema") + .default, + "anonymizedrepositories", + ); + expect( + (await users.findById(actorId).lean()).reviewConsentRevision, + ).to.equal(undefined); + expect( + (await repos.findById(repoId).lean()).reviewConsentRevision, + ).to.equal(undefined); + expect( + (await connection.db.collection("users").findOne({ _id: actorId })) + .reviewConsentRevision, + ).to.equal(2); + expect( + ( + await connection.db + .collection("anonymizedrepositories") + .findOne({ _id: repoId }) + ).reviewConsentRevision, + ).to.equal(2); + }); + it("measures durable receipt replay when explicitly requested", async function () { + if (!process.env.TEST_REVIEW_CONSENT_PERF_REPORT) this.skip(); + const quote = await store.preview( + actor(), + "synthetic-repository", + request(), + ); + const original = await store.confirm(actor(), quote.ticket, confirmation()); + const times = []; + for (let n = 0; n < 100; n++) { + const start = process.hrtime.bigint(); + expect( + await store.confirm(actor(), quote.ticket, confirmation()), + ).to.deep.equal(original); + times.push(Number(process.hrtime.bigint() - start) / 1e6); + } + times.sort((a, b) => a - b); + expect( + await connection.db.collection("review_owner_consents").countDocuments(), + ).to.equal(1); + require("fs").writeFileSync( + process.env.TEST_REVIEW_CONSENT_PERF_REPORT, + JSON.stringify( + { + calls: times.length, + database: "disposable loopback MongoDB replica set", + synthetic: true, + meanMs: times.reduce((a, b) => a + b, 0) / times.length, + medianMs: times[50], + p95Ms: times[94], + }, + null, + 2, + ) + "\n", + ); + }); +});