All files / src/cron ageVerificationAuditReconcile.js

98.61% Statements 71/72
93.47% Branches 43/46
100% Functions 4/4
100% Lines 69/69

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252                                                      1x 1x 1x   1x 1x             1x                               57x 18x 5x 5x   13x 5x   8x                               12x         12x             11x 11x   11x             11x 11x 3x 3x 2x     9x                       9x           9x 7x   9x               1x   9x                       16x 16x         16x 16x 16x 16x 16x 16x                 16x 17x 17x 17x   16x       3x 3x     13x 13x 1x 1x       1x     12x 12x 12x 3x 3x     9x 9x 8x 8x             2x 2x                     16x                 16x                   1x       1x 1x 1x 1x 1x  
/**
 * Cron job: back-fill missing age-verification audit-log entries.
 *
 * The admin decision flow (`admin-age-verification.js`) commits the
 * decision transaction first, then writes the audit-log entry as a
 * post-commit best-effort step. If the audit write fails (Firestore
 * outage, rules misconfig, network drop), the decision is committed
 * but no audit-log row exists — a compliance gap (OSA / GDPR
 * traceability).
 *
 * The route handler returns the failure to the admin via the
 * `auditWritten=false` response flag, but if the admin doesn't notice
 * (or if the failure happens AFTER the response is already sent), the
 * gap silently persists.
 *
 * This job runs daily, scans submissions whose decision committed in
 * the last 7 days, and writes a remediation audit entry for any that
 * still have no matching `auditLog` row. The 7-day window is wide
 * enough to absorb a multi-day Firestore outage but narrow enough that
 * Firestore reads stay cheap.
 *
 * Idempotency: the matching key is the submission ID itself — every
 * remediation entry stores `details.fromSubmissionId` and the query
 * checks for that field before writing. A second run after a
 * successful first-run write is a no-op.
 */
 
const { db } = require('../utils/firebase');
const { now } = require('../utils/helpers');
const log = require('../utils/log');
 
const MS_PER_DAY = 86_400_000;
const SCAN_WINDOW_MS = 7 * MS_PER_DAY;
 
// Maps the submission-doc `status` field (written by the route
// handlers) to the corresponding `action` string used by the audit
// log. The handlers actually write `dob_modified` for the modify-DOB
// path; the other entries are defensive in case the wire format
// changes without an audit-side update.
const STATUS_ACTION_MAP = Object.freeze({
  approved: 'age_verification_approved',
  rejected: 'age_verification_rejected',
  dob_modified: 'age_verification_dob_modified',
  'modify-dob': 'age_verification_dob_modified',
  modifyDob: 'age_verification_dob_modified',
});
 
// Coerce a stored timestamp value to epoch ms. Submission writers in
// `admin-age-verification.js` use plain `now()` (number), but a future
// migration or backfill could land Firestore `Timestamp` objects or
// the `{seconds, nanoseconds}` plain-object shape. Returning `null`
// for unknown shapes lets callers explicitly choose how to handle the
// gap (skip vs back-fill anyway) instead of silently treating it as
// "no match" and writing a duplicate remediation row.
function toMillis(v) {
  if (typeof v === 'number' && Number.isFinite(v)) return v;
  if (v && typeof v.toMillis === 'function') {
    const ms = v.toMillis();
    return typeof ms === 'number' && Number.isFinite(ms) ? ms : null;
  }
  if (v && typeof v.seconds === 'number') {
    return v.seconds * 1000 + Math.floor((v.nanoseconds || 0) / 1_000_000);
  }
  return null;
}
 
/**
 * Find an existing audit-log row that this submission's decision
 * should have written. Two match paths:
 *   1. Already-reconciled rows tag themselves with `fromSubmissionId`.
 *      That's the cheap idempotency check — `where` query.
 *   2. Original-write rows (from the route handler) have no
 *      `fromSubmissionId` but match on `actionType + targetId +
 *      timestamp ≈ decisionAt`. We accept ±10 min skew to absorb
 *      clock drift between the route's `now()` and Firestore's
 *      server-side timestamp.
 */
async function hasExistingAudit(submission, action) {
  // Path 1: idempotency — already-reconciled tagged rows.
  const tagged = await db
    .collection('auditLog')
    .where('details.fromSubmissionId', '==', submission.id)
    .limit(1)
    .get();
  if (!tagged.empty) return true;
 
  // Path 2: original write match. We can't index on `timestamp ≈ X`
  // server-side without a composite index, so query on actionType +
  // targetId and filter the timestamp client-side. Bounded by `limit`
  // because age-verification decisions per user are rare (≤2-3 in
  // realistic scenarios).
  const decisionAt = toMillis(submission.decisionAt);
  Iif (decisionAt === null) return false;
 
  const candidates = await db
    .collection('auditLog')
    .where('actionType', '==', action)
    .where('targetId', '==', String(submission.userId))
    .limit(20)
    .get();
 
  const window = 10 * 60_000; // ±10 min
  for (const doc of candidates.docs) {
    const ts = toMillis(doc.data().timestamp);
    if (ts !== null && Math.abs(ts - decisionAt) < window) {
      return true;
    }
  }
  return false;
}
 
/**
 * Build the remediation audit row for a submission missing its
 * original entry. Mirrors what the route handler would have written,
 * minus the admin's free-text reason (we don't have it on the
 * submission doc — original reason is in the route's request body
 * which is no longer accessible). Reason is replaced with a
 * remediation marker.
 */
function buildRemediationEntry(submission, action) {
  const details = {
    fromSubmissionId: submission.id,
    reconciledAt: now(),
    originalDecisionAt: toMillis(submission.decisionAt),
    note: 'Reconciled by ageVerificationAuditReconcile cron — original audit write failed at decision time.',
  };
  if (action === 'age_verification_approved') {
    details.method = submission.idMethod || 'unknown';
  }
  if (action === 'age_verification_dob_modified') {
    // Modify-DOB submissions don't store the new/old DOB on the
    // submission doc — the user-doc has the new value but oldDob is
    // lost once the transaction commits. Best-effort: omit and rely
    // on the note + originalDecisionAt for traceability. Operators
    // can cross-reference user-doc history if a real audit gap is
    // discovered, but this remediation entry won't reconstruct the
    // old DOB.
    details.note += ' DOB delta not captured — see user-doc history.';
  }
  return {
    adminUid: typeof submission.decidedBy === 'number' ? submission.decidedBy : 0,
    action,
    actionType: action,
    targetType: 'user',
    targetId: String(submission.userId),
    details,
    timestamp: now(),
  };
}
 
async function ageVerificationAuditReconcile() {
  const cutoff = now() - SCAN_WINDOW_MS;
  const snap = await db
    .collection('ageVerificationSubmissions')
    .where('decisionAt', '>=', cutoff)
    .get();
 
  let scanned = 0;
  let reconciled = 0;
  let skippedPending = 0;
  let skippedAlreadyAudited = 0;
  let skippedUnknownStatus = 0;
  let failed = 0;
 
  // Per-doc isolation: a transient Firestore read failure or a
  // single malformed submission must not abort the whole back-fill.
  // This is a compliance remediation path — failing to remediate
  // doc N+1 because doc N threw means another 23 hours of OSA/GDPR
  // gap. Track failures in a counter so they surface in the summary
  // and the catastrophic-failure alert in `cron/index.js` can still
  // distinguish "everything broke" from "one row had bad data".
  for (const doc of snap.docs) {
    scanned++;
    try {
      const data = doc.data();
 
      if (data.status === 'pending' || toMillis(data.decisionAt) === null) {
        // Pending docs shouldn't reach this query (filtered by
        // decisionAt) but defend anyway — a doc could be partially-
        // committed if the route crashed mid-transaction.
        skippedPending++;
        continue;
      }
 
      const action = STATUS_ACTION_MAP[data.status];
      if (!action) {
        skippedUnknownStatus++;
        log.warn('ageVerificationAuditReconcile', 'Unknown submission status', {
          submissionId: doc.id,
          status: data.status,
        });
        continue;
      }
 
      const submission = { id: doc.id, ...data };
      const exists = await hasExistingAudit(submission, action);
      if (exists) {
        skippedAlreadyAudited++;
        continue;
      }
 
      const entry = buildRemediationEntry(submission, action);
      await db.collection('auditLog').add(entry);
      reconciled++;
      log.warn('ageVerificationAuditReconcile', 'Back-filled missing audit entry', {
        submissionId: doc.id,
        targetUserId: submission.userId,
        action,
        originalDecisionAt: toMillis(submission.decisionAt),
      });
    } catch (err) {
      failed++;
      log.error(
        'ageVerificationAuditReconcile',
        'Per-doc remediation failed — continuing to next submission',
        {
          submissionId: doc.id,
          error: err && err.message ? err.message : String(err),
        },
      );
    }
  }
 
  log.info('ageVerificationAuditReconcile', 'Scan complete', {
    scanned,
    reconciled,
    skippedPending,
    skippedAlreadyAudited,
    skippedUnknownStatus,
    failed,
  });
 
  return {
    scanned,
    reconciled,
    skippedPending,
    skippedAlreadyAudited,
    skippedUnknownStatus,
    failed,
  };
}
 
module.exports = ageVerificationAuditReconcile;
// Exported for unit-test access — tests stub `db` + `now` and assert
// internal behaviour without exercising the wrapped cron.schedule
// call.
module.exports.STATUS_ACTION_MAP = STATUS_ACTION_MAP;
module.exports.SCAN_WINDOW_MS = SCAN_WINDOW_MS;
module.exports.hasExistingAudit = hasExistingAudit;
module.exports.buildRemediationEntry = buildRemediationEntry;
module.exports.toMillis = toMillis;