All files / src/utils fcm.js

96.22% Statements 51/53
95% Branches 38/40
100% Functions 9/9
95.74% Lines 45/47

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            62x 62x 62x 62x             62x 62x     7x         7x                                               24x 24x     24x 24x       23x       2x   21x 21x   1x     1x   21x   17x           17x                         44x   40x                     20x     20x 7x 7x 7x     24x   13x         13x 13x 28x 6x 6x           4x   2x               13x             4x 2x 1x 1x                             9x       11x     62x            
/**
 * Shared FCM (Firebase Cloud Messaging) utilities.
 *
 * Extracted from rooms.js, conversations.js, and reports.js to eliminate duplication.
 */
 
const { messaging, db, FieldValue } = require('./firebase');
const log = require('./log');
const { effectiveCohort } = require('./firebase-claims');
const { auditFcmCohortDrop } = require('./segregation-audit');
 
// Local-mode FCM capture buffer for integration tests.
// In NODE_ENV=local the route never contacts real FCM — we record the
// payload here so a Playwright test can verify the contract via
// /api/test/fcm-captures (test-helpers.js). Cleared between tests
// via /api/test/fcm-captures/clear. Production never touches this.
const _fcmCaptures = [];
const FCM_CAPTURE_LIMIT = 1000;
 
function captureLocal(tokens, data) {
  Iif (_fcmCaptures.length >= FCM_CAPTURE_LIMIT) {
    // Bound the buffer so a long-lived dev process can't OOM.
    // Drop the oldest — tests should clear before running anyway.
    _fcmCaptures.shift();
  }
  _fcmCaptures.push({
    tokens: [...tokens],
    data: { ...data },
    ts: Date.now(),
  });
}
 
/**
 * UK OSA #17 PR 11 — defence-in-depth cohort filter at the FCM
 * dispatcher. Returns true when the push must be silently dropped
 * (cross-cohort, fail-closed on read errors). Returns false when the
 * push is safe to send (same cohort, or filter is not opt-in for this
 * call). System / admin / self pushes opt out by passing no IDs and
 * keep their existing behavior — no Firestore reads, no filter cost.
 *
 * Timing note: opt-in callers pay two parallel Firestore reads (sender
 * + recipient). Both reads happen regardless of cohort outcome, so the
 * SAME-cohort and CROSS-cohort paths are timing-symmetric — an
 * attacker observing dispatch latency cannot distinguish "allowed" vs
 * "dropped". The only timing signal is "filter opted in" vs "legacy
 * caller" (one round-trip pair vs zero), which corresponds to the
 * already-public call-site distinction (user→user vs system).
 */
async function isCrossCohortDispatch(senderUniqueId, recipientUniqueId) {
  const senderId = String(senderUniqueId);
  const recipientId = String(recipientUniqueId);
  let senderCohort;
  let recipientCohort;
  try {
    const [senderSnap, recipientSnap] = await Promise.all([
      db.doc(`users/${senderId}`).get(),
      db.doc(`users/${recipientId}`).get(),
    ]);
    if (!senderSnap.exists || !recipientSnap.exists) {
      // Fail-closed: a missing user doc is exactly the kind of state
      // the upstream gate may not have caught. Dropping costs at most
      // one missed push; allowing it could leak presence.
      return true;
    }
    senderCohort = effectiveCohort(senderSnap.data());
    recipientCohort = effectiveCohort(recipientSnap.data());
  } catch (err) {
    log.error('fcm', 'cohort lookup failed; dropping push (fail-closed)', {
      error: err?.message || String(err),
    });
    return true;
  }
  if (senderCohort === recipientCohort) return false;
  // Fire-and-forget — auditFcmCohortDrop swallows write errors.
  auditFcmCohortDrop({
    sourceUniqueId: senderId,
    sourceCohort: senderCohort,
    targetUniqueId: recipientId,
    targetCohort: recipientCohort,
  });
  return true;
}
 
/**
 * Send a data-only FCM message to multiple tokens via Firebase Admin SDK.
 * All values are stringified (FCM data messages require string values).
 * Returns a list of invalid tokens that should be cleaned up.
 *
 * Optional `{ senderUniqueId, recipientUniqueId }` opts the call into
 * the UK OSA #17 PR 11 cohort filter — when both are provided and
 * distinct, cross-cohort pairs are silently dropped at dispatch.
 */
async function sendFcmToTokens(tokens, data, { senderUniqueId, recipientUniqueId } = {}) {
  if (!tokens || tokens.length === 0) return [];
 
  if (
    senderUniqueId !== undefined &&
    senderUniqueId !== null &&
    recipientUniqueId !== undefined &&
    recipientUniqueId !== null &&
    String(senderUniqueId) !== String(recipientUniqueId) &&
    (await isCrossCohortDispatch(senderUniqueId, recipientUniqueId))
  ) {
    // Silent drop. No local-mode capture (cross-cohort drops must not
    // pollute integration-test buffers — tests assert "no payload"
    // means "no payload", not "captured but flagged").
    return [];
  }
 
  if (process.env.NODE_ENV === 'local') {
    captureLocal(tokens, data);
    log.info('fcm', `[FCM-LOCAL] Would send to ${tokens.length} tokens: ${data?.title}`);
    return [];
  }
 
  const stringData = Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)]));
 
  const result = await messaging.sendEachForMulticast({
    tokens,
    data: stringData,
  });
 
  const invalidTokens = [];
  result.responses.forEach((resp, i) => {
    if (resp.error) {
      const code = resp.error.code;
      if (
        code === 'messaging/invalid-registration-token' ||
        code === 'messaging/registration-token-not-registered' ||
        code === 'messaging/sender-id-mismatch' ||
        code === 'messaging/invalid-argument'
      ) {
        invalidTokens.push(tokens[i]);
      } else {
        log.warn('fcm', `FCM send failed for token index ${i}`, {
          code,
          message: resp.error.message,
        });
      }
    }
  });
 
  return invalidTokens;
}
 
/**
 * Remove invalid FCM tokens from a user's doc using arrayRemove.
 */
async function cleanupInvalidTokens(invalidTokens, userId) {
  if (!invalidTokens || invalidTokens.length === 0 || !userId) return;
  if (process.env.NODE_ENV === 'local') return;
  try {
    await db.doc(`users/${userId}`).update({
      fcmTokens: FieldValue.arrayRemove(...invalidTokens),
    });
  } catch (err) {
    log.error('fcm', 'Failed to clean invalid tokens', { userId, error: err.message });
  }
}
 
/**
 * Test helpers — local-mode only. Used by the integration suite to
 * verify FCM payload shape without hitting real Firebase Cloud
 * Messaging. Returns a defensive copy so callers can't mutate the
 * buffer in place.
 */
function getFcmCaptures() {
  return _fcmCaptures.map((c) => ({ ...c, tokens: [...c.tokens], data: { ...c.data } }));
}
 
function clearFcmCaptures() {
  _fcmCaptures.length = 0;
}
 
module.exports = {
  sendFcmToTokens,
  cleanupInvalidTokens,
  getFcmCaptures,
  clearFcmCaptures,
};