Ctrl K
ComunidadGuías…

test/dashboard-email-code-login.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Email-code login + OAuth-only account classification.
//
// Recon: .agent/loginrecon-20261022/LoginRecon.md §4.0/§4.1. The live account is
// OAuth/email-code-only: `POST /_devin-auth/connections` returns
// `auth_method.has_password:false` while `CheckUserLoginMethod` returns an EMPTY
// `{}`. The old code fell back to connections, read `connections[email].enabled`
// as "has a password", attempted `password/login`, and reported the upstream 401
// as ERR_INVALID_CREDENTIALS — a wrong-method signal disguised as a bad password.
//
// Zero network: httpsRequest is routed through __setLoginTransportForTests.
// Email addresses, codes and tokens here are throwaway literals; no live email
// is sent and no real credential is used.

import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { log } from '../src/config.js';
import {
  windsurfLogin,
  probeLoginMethod,
  emailLoginStart,
  emailLoginComplete,
  checkEmailLocked,
  __setLoginTransportForTests,
  _resetEmailLockoutForTests,
  _resetEmailFlowsForTests,
} from '../src/dashboard/windsurf-login.js';

const EMAIL = 'oauth-only@example.test';
const PASSWORD = 'throwaway-not-real';
const CODE = '123456';
const VERIFY_TOKEN = 'verify-token-throwaway-value';
const AUTH1_TOKEN = 'auth1-throwaway-value';

function setFlag(on) {
  if (on) process.env.DEVIN_CONNECT_LOGIN_HOST_FALLBACK = '1';
  else delete process.env.DEVIN_CONNECT_LOGIN_HOST_FALLBACK;
}

// First-substring-match route table. Raw requests (PostAuth) get a Buffer.
// Avoid 5xx replies so the retry backoff never fires (keeps the suite fast).
function makeTransport(routes) {
  const seen = [];
  const requests = [];
  const fn = async (url, opts, postData) => {
    seen.push(url);
    requests.push({ url, opts, body: postData });
    for (const [matcher, reply] of routes) {
      if (url.includes(matcher)) {
        const r = typeof reply === 'function' ? reply(url, opts, postData) : reply;
        if (r instanceof Error) throw r;
        if (opts.raw) {
          const buf = Buffer.isBuffer(r.data) ? r.data
            : Buffer.from(typeof r.data === 'string' ? r.data : JSON.stringify(r.data));
          return { status: r.status, data: buf };
        }
        return { status: r.status, data: r.data };
      }
    }
    throw new Error(`unexpected request to ${url}`);
  };
  fn.seen = seen;
  fn.requests = requests;
  return fn;
}

// The live account's exact probe pair: CheckUserLoginMethod `{}` + connections
// with has_password:false and an ENABLED email connection (email *code*).
const CHECK_METHOD_EMPTY = ['SeatManagementService/CheckUserLoginMethod', { status: 200, data: {} }];
const CONNECTIONS_OAUTH_ONLY = ['windsurf.com/_devin-auth/connections', {
  status: 200,
  data: {
    connections: [
      { id: 'email', type: 'email', enabled: true, client_id: null },
      { id: 'google-windsurf', type: 'google', enabled: true, client_id: 'google-client-id' },
      { id: 'github-windsurf', type: 'github', enabled: true, client_id: 'github-client-id' },
    ],
    sso_connections: [],
    auth_method: { method: 'auth1', has_password: false, enterprise_sso_domain: false },
  },
}];
const PASSWORD_LOGIN_REJECTED = ['windsurf.com/_devin-auth/password/login', { status: 401, data: { detail: 'Invalid email or password' } }];
const EMAIL_START_OK = ['windsurf.com/_devin-auth/email/start', {
  status: 200, data: { email_verification_token: VERIFY_TOKEN },
}];
const EMAIL_COMPLETE_OK = ['windsurf.com/_devin-auth/email/complete', {
  status: 200, data: { token: AUTH1_TOKEN },
}];
const POSTAUTH_OK = ['WindsurfPostAuth', {
  status: 200, data: { sessionToken: 'devin-session-token$EMAILCODE', accountId: 'account-email-code' },
}];

beforeEach(() => {
  _resetEmailLockoutForTests();
  _resetEmailFlowsForTests();
  setFlag(false);
});
afterEach(() => {
  __setLoginTransportForTests(null);
  _resetEmailLockoutForTests();
  _resetEmailFlowsForTests();
  setFlag(false);
});

describe('OAuth-only account classification', () => {
  it('has_password:false is terminal — no password attempt, no ERR_INVALID_CREDENTIALS', async () => {
    const t = makeTransport([CHECK_METHOD_EMPTY, CONNECTIONS_OAUTH_ONLY, PASSWORD_LOGIN_REJECTED]);
    __setLoginTransportForTests(t);

    await assert.rejects(() => windsurfLogin(EMAIL, PASSWORD, null), (err) => {
      assert.equal(err.code, 'ERR_NO_PASSWORD_OAUTH_ACCOUNT',
        'an OAuth-only account must be classified by its login method, not by a password rejection');
      assert.equal(err.method, 'email_code', 'the caller must be told which method actually works');
      assert.equal(err.isAuthFail, false, 'a wrong-method signal is not a credential failure');
      assert.deepEqual(err.oauthProviders, ['google', 'github']);
      return true;
    });

    assert.deepEqual(
      t.seen.filter(u => u.includes('/password/login')), [],
      'an account that has no password must never be sent to password/login',
    );
  });

  it('a no-password answer does not feed the per-email lockout', async () => {
    const t = makeTransport([CHECK_METHOD_EMPTY, CONNECTIONS_OAUTH_ONLY, PASSWORD_LOGIN_REJECTED]);
    __setLoginTransportForTests(t);
    for (let i = 0; i < 4; i++) {
      await assert.rejects(() => windsurfLogin(EMAIL, PASSWORD, null),
        (err) => err.code === 'ERR_NO_PASSWORD_OAUTH_ACCOUNT');
    }
    assert.equal(checkEmailLocked(EMAIL), null,
      'using the wrong login tab must never lock the email (3 strikes are for real credential guesses)');
  });

  it('probe reports the authoritative method and skips the secondary probe', async () => {
    const t = makeTransport([CHECK_METHOD_EMPTY, CONNECTIONS_OAUTH_ONLY]);
    __setLoginTransportForTests(t);
    const probe = await probeLoginMethod(EMAIL);
    assert.equal(probe.source, 'connections');
    assert.equal(probe.hasPassword, false);
    assert.equal(probe.userExists, true);
    assert.equal(probe.emailCode, true);
    assert.deepEqual(probe.oauth, ['google', 'github']);
    assert.deepEqual(t.seen.filter(u => u.includes('CheckUserLoginMethod')), [],
      'connections answered, so the unreliable Connect-RPC probe must not be consulted');
  });

  it('a password account probes as hasPassword:true with no email-code detour', async () => {
    const t = makeTransport([
      ['windsurf.com/_devin-auth/connections', {
        status: 200,
        data: { auth_method: { method: 'auth1', has_password: true } },
      }],
    ]);
    __setLoginTransportForTests(t);
    const probe = await probeLoginMethod(EMAIL);
    assert.equal(probe.hasPassword, true);
    assert.equal(probe.emailCode, false);
  });

  it('connections unreachable → CheckUserLoginMethod enriches, `{}` stays unknown', async () => {
    const unreachable = ['windsurf.com/_devin-auth/connections', { status: 404, data: { detail: 'not found' } }];
    const t1 = makeTransport([unreachable, ['SeatManagementService/CheckUserLoginMethod', { status: 200, data: { userExists: true, hasPassword: false } }]]);
    __setLoginTransportForTests(t1);
    const fromCheck = await probeLoginMethod(EMAIL);
    assert.equal(fromCheck.source, 'check_user_login_method');
    assert.equal(fromCheck.hasPassword, false, 'a positive hasPassword:false from the secondary probe is usable');
    assert.equal(fromCheck.emailCode, true);

    const t2 = makeTransport([unreachable, CHECK_METHOD_EMPTY]);
    __setLoginTransportForTests(t2);
    const unknown = await probeLoginMethod(EMAIL);
    assert.equal(unknown.source, 'unknown');
    assert.equal(unknown.hasPassword, null,
      'an empty {} must be unknown — never password-capable and never "no password"');
    assert.equal(unknown.userExists, null);
  });
});

describe('emailLoginStart', () => {
  it('sends the documented payload and returns only an opaque flowId', async () => {
    const t = makeTransport([EMAIL_START_OK]);
    __setLoginTransportForTests(t);
    const out = await emailLoginStart(EMAIL);
    assert.equal(out.sent, true);
    assert.match(out.flowId, /^[0-9a-f]{32}$/, 'flowId must be an opaque 128-bit id');
    assert.equal(out.expiresInMs, 10 * 60 * 1000);
    assert.equal(JSON.stringify(out).includes(VERIFY_TOKEN), false,
      'the upstream verification token must never reach the browser');

    const req = t.requests.find(r => r.url.includes('/email/start'));
    assert.deepEqual(JSON.parse(req.body), { email: EMAIL, mode: 'login', product: 'Windsurf' });
    assert.match(req.opts.headers['Content-Type'], /application\/json/);
  });

  it('never logs the verification token', async () => {
    const t = makeTransport([EMAIL_START_OK]);
    __setLoginTransportForTests(t);
    const captured = [];
    const origInfo = log.info;
    const origWarn = log.warn;
    log.info = (...args) => captured.push(args.join(' '));
    log.warn = (...args) => captured.push(args.join(' '));
    try {
      await emailLoginStart(EMAIL);
    } finally {
      log.info = origInfo;
      log.warn = origWarn;
    }
    assert.ok(captured.length > 0, 'the start step must be logged');
    assert.equal(captured.some(line => line.includes(VERIFY_TOKEN)), false, 'token leaked into logs');
    assert.equal(captured.some(line => line.includes(EMAIL)), false, 'raw email leaked into logs');
  });

  it('rate-limits resends per email', async () => {
    const t = makeTransport([EMAIL_START_OK]);
    __setLoginTransportForTests(t);
    await emailLoginStart(EMAIL);
    await assert.rejects(() => emailLoginStart(EMAIL), (err) => {
      assert.equal(err.code, 'ERR_EMAIL_CODE_COOLDOWN');
      assert.ok(err.retryAfterMs > 0 && err.retryAfterMs <= 30_000);
      return true;
    });
    assert.equal(t.seen.filter(u => u.includes('/email/start')).length, 1,
      'the cooldown must stop the second upstream send');
  });

  it('surfaces an upstream send failure as ERR_EMAIL_START_FAILED', async () => {
    const t = makeTransport([['windsurf.com/_devin-auth/email/start', { status: 400, data: { detail: 'invalid email' } }]]);
    __setLoginTransportForTests(t);
    await assert.rejects(() => emailLoginStart(EMAIL), (err) => {
      assert.equal(err.code, 'ERR_EMAIL_START_FAILED');
      assert.equal(err.detail, 'invalid email');
      return true;
    });
  });

  it('a FAILED send still starts the cooldown (a failing upstream cannot be hammered)', async () => {
    const t = makeTransport([['windsurf.com/_devin-auth/email/start', { status: 400, data: { detail: 'sender unavailable' } }]]);
    __setLoginTransportForTests(t);
    await assert.rejects(() => emailLoginStart(EMAIL),
      (err) => err.code === 'ERR_EMAIL_START_FAILED');

    // Second attempt inside the window: rejected locally, upstream not called
    // again. Without the pre-call stamp this retried immediately, forever.
    await assert.rejects(() => emailLoginStart(EMAIL), (err) => {
      assert.equal(err.code, 'ERR_EMAIL_CODE_COOLDOWN');
      assert.ok(err.retryAfterMs > 0 && err.retryAfterMs <= 30_000);
      return true;
    });
    assert.equal(t.seen.filter(u => u.includes('/email/start')).length, 1,
      'the failed send must not be retried upstream inside the cooldown');
  });
});

describe('emailLoginComplete', () => {
  it('verifies the code, exchanges PostAuth, and returns the windsurfLogin shape', async () => {
    const t = makeTransport([EMAIL_START_OK, EMAIL_COMPLETE_OK, POSTAUTH_OK]);
    __setLoginTransportForTests(t);
    const { flowId } = await emailLoginStart(EMAIL);
    const result = await emailLoginComplete(flowId, CODE);

    assert.equal(result.apiKey, 'devin-session-token$EMAILCODE');
    assert.equal(result.sessionToken, 'devin-session-token$EMAILCODE');
    assert.equal(result.email, EMAIL);
    assert.equal(result.name, EMAIL);
    assert.equal(result.viaEmailCode, true);
    assert.equal(result.apiServerUrl, '');

    const req = t.requests.find(r => r.url.includes('/email/complete'));
    assert.deepEqual(JSON.parse(req.body), {
      email_verification_token: VERIFY_TOKEN, code: CODE, mode: 'login',
    });
  });

  it('consumes the flow: a replayed flowId is rejected without a second upstream call', async () => {
    const t = makeTransport([EMAIL_START_OK, EMAIL_COMPLETE_OK, POSTAUTH_OK]);
    __setLoginTransportForTests(t);
    const { flowId } = await emailLoginStart(EMAIL);
    await emailLoginComplete(flowId, CODE);
    const callsAfterSuccess = t.requests.length;

    await assert.rejects(() => emailLoginComplete(flowId, CODE), (err) => {
      assert.equal(err.code, 'ERR_EMAIL_FLOW_USED');
      return true;
    });
    assert.equal(t.requests.length, callsAfterSuccess, 'a used flow must not hit upstream again');
  });

  it('a wrong code is rejected, counted, and leaves the flow usable for a retry', async () => {
    let completeCalls = 0;
    const t = makeTransport([
      EMAIL_START_OK,
      ['windsurf.com/_devin-auth/email/complete', () => {
        completeCalls += 1;
        return completeCalls === 1
          ? { status: 400, data: { detail: 'Invalid verification code' } }
          : { status: 200, data: { token: AUTH1_TOKEN } };
      }],
      POSTAUTH_OK,
    ]);
    __setLoginTransportForTests(t);
    const { flowId } = await emailLoginStart(EMAIL);

    await assert.rejects(() => emailLoginComplete(flowId, '000000'), (err) => {
      assert.equal(err.code, 'ERR_EMAIL_CODE_REJECTED');
      assert.equal(err.isAuthFail, true);
      assert.equal(err.attemptsRemaining, 4);
      return true;
    });

    const result = await emailLoginComplete(flowId, CODE);
    assert.equal(result.apiKey, 'devin-session-token$EMAILCODE', 'a typo must not burn the flow');
  });

  it('wrong codes feed the existing per-email lockout', async () => {
    const t = makeTransport([
      EMAIL_START_OK,
      ['windsurf.com/_devin-auth/email/complete', { status: 400, data: { detail: 'Invalid verification code' } }],
    ]);
    __setLoginTransportForTests(t);
    const { flowId } = await emailLoginStart(EMAIL);
    for (let i = 0; i < 3; i++) {
      await assert.rejects(() => emailLoginComplete(flowId, '000000'),
        (err) => err.code === 'ERR_EMAIL_CODE_REJECTED');
    }
    assert.ok(checkEmailLocked(EMAIL) > 0, 'three rejected codes must trip the configured lockout');
    await assert.rejects(() => emailLoginComplete(flowId, '000000'),
      (err) => err.code === 'ERR_EMAIL_LOCKED');
  });

  it('rejects an expired flow', async (t) => {
    t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
    const transport = makeTransport([EMAIL_START_OK, EMAIL_COMPLETE_OK, POSTAUTH_OK]);
    __setLoginTransportForTests(transport);
    const { flowId } = await emailLoginStart(EMAIL);
    t.mock.timers.tick(10 * 60 * 1000 + 1);
    await assert.rejects(() => emailLoginComplete(flowId, CODE), (err) => {
      assert.equal(err.code, 'ERR_EMAIL_FLOW_EXPIRED');
      assert.equal(err.statusCode, 410);
      return true;
    });
  });

  it('rejects an unknown flow and a malformed code without touching upstream', async () => {
    const t = makeTransport([EMAIL_START_OK, EMAIL_COMPLETE_OK]);
    __setLoginTransportForTests(t);
    await assert.rejects(() => emailLoginComplete('deadbeef', CODE),
      (err) => err.code === 'ERR_EMAIL_FLOW_NOT_FOUND');
    const { flowId } = await emailLoginStart(EMAIL);
    await assert.rejects(() => emailLoginComplete(flowId, 'not-a-code'),
      (err) => err.code === 'ERR_EMAIL_CODE_INVALID');
    assert.deepEqual(t.seen.filter(u => u.includes('/email/complete')), [],
      'input validation must happen before any upstream call');
  });

  it('never logs the code or the upstream token', async () => {
    const t = makeTransport([EMAIL_START_OK, EMAIL_COMPLETE_OK, POSTAUTH_OK]);
    __setLoginTransportForTests(t);
    const { flowId } = await emailLoginStart(EMAIL);
    const captured = [];
    const origInfo = log.info;
    const origWarn = log.warn;
    log.info = (...args) => captured.push(args.join(' '));
    log.warn = (...args) => captured.push(args.join(' '));
    try {
      await emailLoginComplete(flowId, CODE);
    } finally {
      log.info = origInfo;
      log.warn = origWarn;
    }
    assert.ok(captured.length > 0, 'the complete step must be logged');
    assert.equal(captured.some(line => line.includes(CODE)), false, 'verification code leaked into logs');
    assert.equal(captured.some(line => line.includes(AUTH1_TOKEN)), false, 'Auth1 token leaked into logs');
    assert.equal(captured.some(line => line.includes(VERIFY_TOKEN)), false, 'verification token leaked into logs');
    assert.equal(captured.some(line => line.includes('devin-session-token$EMAILCODE')), false,
      'session token leaked into logs');
  });
});

describe('email-code host fallback (app.devin.ai, flag-gated)', () => {
  const DEVIN_START_OK = ['app.devin.ai/api/auth1/email/start', {
    status: 200, data: { email_verification_token: 'devin-verify-token-throwaway' },
  }];
  const DEVIN_COMPLETE_OK = ['app.devin.ai/api/auth1/email/complete', {
    status: 200, data: { token: 'auth1-devin-throwaway', user_id: 'user-1' },
  }];

  it('falls back to app.devin.ai when the windsurf email endpoint is gone', async () => {
    setFlag(true);
    const t = makeTransport([
      ['windsurf.com/_devin-auth/email/start', { status: 404, data: { detail: 'not found' } }],
      DEVIN_START_OK,
      DEVIN_COMPLETE_OK,
      POSTAUTH_OK,
    ]);
    __setLoginTransportForTests(t);
    const { flowId } = await emailLoginStart(EMAIL);
    const result = await emailLoginComplete(flowId, CODE);

    assert.equal(result.apiKey, 'devin-session-token$EMAILCODE');
    assert.equal(result.viaHost, 'app.devin.ai');
    const devinStart = t.requests.find(r => r.url.includes('app.devin.ai/api/auth1/email/start'));
    assert.deepEqual(JSON.parse(devinStart.body), { email: EMAIL, mode: 'login' },
      'the Devin host payload has no product field');
    assert.ok(t.seen.some(u => u.includes('app.devin.ai/api/auth1/email/complete')),
      'the verification token must be completed on the host that issued it');
  });

  it('flag OFF: no second host is contacted', async () => {
    setFlag(false);
    const t = makeTransport([
      ['windsurf.com/_devin-auth/email/start', { status: 404, data: { detail: 'not found' } }],
      DEVIN_START_OK,
    ]);
    __setLoginTransportForTests(t);
    await assert.rejects(() => emailLoginStart(EMAIL), (err) => {
      assert.equal(err.code, 'ERR_EMAIL_START_FAILED');
      return true;
    });
    assert.deepEqual(t.seen.filter(u => u.includes('app.devin.ai')), []);
  });

  it('a 4xx rejection is not retried on the second host (no double-spent attempt)', async () => {
    setFlag(true);
    const t = makeTransport([
      EMAIL_START_OK,
      ['windsurf.com/_devin-auth/email/complete', { status: 400, data: { detail: 'Invalid verification code' } }],
      DEVIN_COMPLETE_OK,
    ]);
    __setLoginTransportForTests(t);
    const { flowId } = await emailLoginStart(EMAIL);
    await assert.rejects(() => emailLoginComplete(flowId, '000000'),
      (err) => err.code === 'ERR_EMAIL_CODE_REJECTED');
    assert.deepEqual(t.seen.filter(u => u.includes('app.devin.ai')), [],
      'a credential rejection is upstream answering, not an availability failure');
  });
});