62 lines
4.1 KiB
JavaScript
62 lines
4.1 KiB
JavaScript
// Tier-2 rules verification (LIVE): mint a MEMBER token (QA) → raw Firestore REST → confirm the
|
|
// session-progress arrays can only grow by the writer's OWN uid. Foreign-uid adds + removals must be
|
|
// DENIED (403). These are rejected writes, so nothing mutates. (Own-uid add is verified via the app.)
|
|
const admin = require('firebase-admin')
|
|
const path = require('path')
|
|
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
|
const PROJECT = 'closer-app-22014'
|
|
const APIKEY = 'AIzaSyDAD7FnEYzhMsil41SzJ1XMjUNnJWmjie8'
|
|
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
|
const QA = 'Y05AKO2IlTPMa0JQW1BiNIM0uzK2' // a real MEMBER
|
|
const SAM = 'imDjjOTTQvXGGjyUhUc5JSeHWkU2' // the partner (foreign, from QA's perspective)
|
|
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
|
const db = admin.firestore()
|
|
const docBase = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents`
|
|
|
|
async function memberToken() {
|
|
const custom = await admin.auth().createCustomToken(QA)
|
|
const r = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${APIKEY}`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: custom, returnSecureToken: true })
|
|
})
|
|
const j = await r.json(); if (!j.idToken) throw new Error('no idToken: ' + JSON.stringify(j)); return j.idToken
|
|
}
|
|
const arr = (uids) => ({ arrayValue: uids.length ? { values: uids.map((u) => ({ stringValue: u })) } : {} })
|
|
async function patch(token, sid, field, uids, label, expectDeny) {
|
|
const r = await fetch(`${docBase}/couples/${COUPLE}/sessions/${sid}?updateMask.fieldPaths=${field}`, {
|
|
method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fields: { [field]: arr(uids) } })
|
|
})
|
|
const denied = r.status !== 200
|
|
const good = expectDeny ? denied : !denied
|
|
console.log(` ${good ? '✅' : '❌❌'} ${label}: ${denied ? `DENIED(${r.status})` : 'ALLOWED(200)'} (expected ${expectDeny ? 'deny' : 'allow'})`)
|
|
return good
|
|
}
|
|
|
|
;(async () => {
|
|
// find a session with a non-empty joinedByUsers (for the removal test) + its current arrays
|
|
const snap = await db.collection('couples').doc(COUPLE).collection('sessions').get()
|
|
let target = null
|
|
snap.forEach((d) => { const x = d.data(); if (!target && Array.isArray(x.joinedByUsers) && x.joinedByUsers.length > 0) target = { id: d.id, ...x } })
|
|
if (!target) { snap.forEach((d) => { if (!target && d.id !== '_active') target = { id: d.id, ...d.data() } }) }
|
|
console.log('target session', target.id, 'joinedByUsers=', JSON.stringify(target.joinedByUsers || []), 'completedByUsers=', JSON.stringify(target.completedByUsers || []))
|
|
const tok = await memberToken()
|
|
const cur = Array.isArray(target.joinedByUsers) ? target.joinedByUsers : []
|
|
let bad = 0
|
|
// NEG1: add a foreign/bogus uid to joinedByUsers → deny
|
|
if (!await patch(tok, target.id, 'joinedByUsers', [...new Set([...cur, 'bogus-' + Date.now()])], 'foreign-add joinedByUsers', true)) bad++
|
|
// NEG2: add the PARTNER's uid (spoof) to completedByUsers → deny
|
|
const curC = Array.isArray(target.completedByUsers) ? target.completedByUsers : []
|
|
if (!curC.includes(SAM) || !curC.includes(QA)) {
|
|
if (!await patch(tok, target.id, 'completedByUsers', [...new Set([...curC, QA, SAM])], 'spoof-partner completedByUsers', true)) bad++
|
|
} else {
|
|
console.log(' (skip spoof-completed: both already present on this completed session)')
|
|
}
|
|
// NEG3: removal from a non-empty joinedByUsers → deny
|
|
if (cur.length > 0) { if (!await patch(tok, target.id, 'joinedByUsers', [], 'removal joinedByUsers', true)) bad++ }
|
|
else console.log(' (skip removal: target joinedByUsers empty)')
|
|
await admin.auth().updateUser(QA, {}).catch(() => {}) // no-op; don't delete a real member
|
|
console.log(bad === 0 ? '=== Tier-2 PASS: own-uid-only enforced (foreign/spoof/removal denied) ===' : `=== Tier-2 FAIL: ${bad} unexpected ===`)
|
|
process.exit(bad === 0 ? 0 : 1)
|
|
})().catch((e) => { console.error('FATAL', e.message); process.exit(2) })
|