← back to Costa Rica

test/apple-route.test.js

71 lines

'use strict';
// Route-level regression test for the c2 account-takeover guard (commit 0689909).
// The verifier-layer test (apple.test.js) proves email_verified is a real boolean;
// THIS proves the /auth/apple route only auto-links an Apple identity to an existing
// account when that boolean is true. No DB / no network: pool.query + apple verifier
// are mocked via their shared module objects. Run: node --test
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const express = require('express');

const db = require('../lib/db');
const apple = require('../lib/apple');

let sqls = [];              // every SQL the route runs, in order
let appleClaims = null;     // what the mocked verifier returns

// Mock pool.query: record SQL, return sensible rows for the /auth/apple flow.
db.pool.query = async (sql) => {
  sqls.push(sql);
  if (/SELECT \* FROM app_users WHERE apple_sub/.test(sql)) return { rows: [] };        // no existing apple account
  if (/UPDATE app_users SET apple_sub/.test(sql)) return { rows: [{ id: 1, email: 'a@b.com', role: 'guest', is_host: false }] };
  if (/INSERT INTO app_users \(apple_sub/.test(sql)) return { rows: [{ id: 2, email: 'a@b.com', role: 'guest', is_host: false }] };
  return { rows: [] };
};
apple.verifyIdentityToken = async () => appleClaims;

const { router } = require('../routes/app');
let server, base;
before(async () => {
  const app = express();
  app.use(express.json());
  app.use('/api/app', router);
  await new Promise(r => { server = app.listen(0, r); });
  base = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());

function post(path, body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = http.request(base + path, { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } },
      res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
    req.on('error', reject); req.end(data);
  });
}
const linked = () => sqls.some(s => /UPDATE app_users SET apple_sub/.test(s));

test('SECURITY: email_verified=false → does NOT auto-link, creates a fresh Apple account instead', async () => {
  sqls = []; appleClaims = { sub: 'apple-x', email: 'a@b.com', email_verified: false };
  const r = await post('/api/app/auth/apple', { identity_token: 'stub' });
  assert.equal(r.status, 200);
  assert.equal(linked(), false, 'unverified email must NOT link to the existing account');
  assert.ok(sqls.some(s => /INSERT INTO app_users \(apple_sub/.test(s)), 'a new Apple-owned account should be created');
});

test('email_verified=true → DOES auto-link the Apple identity to the existing account', async () => {
  sqls = []; appleClaims = { sub: 'apple-x', email: 'a@b.com', email_verified: true };
  const r = await post('/api/app/auth/apple', { identity_token: 'stub' });
  assert.equal(r.status, 200);
  assert.equal(linked(), true, 'verified email should link');
  assert.ok(!sqls.some(s => /INSERT INTO app_users \(apple_sub/.test(s)), 'link path should skip the INSERT');
});

test('no email present → no link even if email_verified is somehow true', async () => {
  sqls = []; appleClaims = { sub: 'apple-x', email: null, email_verified: true };
  const r = await post('/api/app/auth/apple', { identity_token: 'stub' });
  assert.equal(r.status, 200);
  assert.equal(linked(), false, 'no email → nothing to link against');
});