test/devin-connect-credentials.test.js
Rama master · réplica de lectura del repositorio original.
import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { tmpdir } from 'os';
import { join } from 'path';
import { rmSync, readFileSync, writeFileSync, mkdtempSync } from 'fs';
import {
getCredKey, isCredStoreEnabled,
storeCredential, getCredential, hasCredential, deleteCredential,
listCredentialEmails, __testing,
} from '../src/devin-connect-credentials.js';
// Each test gets an isolated cred file + key via a synthetic env object.
let dir;
function mkEnv(key = 'test-master-key-123') {
return { DEVIN_CONNECT_CRED_KEY: key, DEVIN_CONNECT_CRED_FILE: join(dir, 'creds.json') };
}
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'wsapi-cred-')); });
afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
describe('credential store — gating', () => {
it('is disabled when no master key is set', () => {
assert.equal(isCredStoreEnabled({}), false);
assert.equal(getCredKey({}), '');
assert.equal(storeCredential('a@b.com', 'pw', {}), false);
assert.equal(getCredential('a@b.com', {}), null);
assert.equal(hasCredential('a@b.com', {}), false);
assert.deepEqual(listCredentialEmails({}), []);
});
it('is enabled when a master key is present', () => {
assert.equal(isCredStoreEnabled(mkEnv()), true);
});
});
describe('credential store — roundtrip', () => {
it('encrypts then decrypts back to the original password', () => {
const env = mkEnv();
assert.equal(storeCredential('User@Example.com', 's3cret-pw!', env), true);
assert.equal(getCredential('user@example.com', env), 's3cret-pw!', 'email is case-insensitive');
assert.equal(hasCredential('USER@EXAMPLE.COM', env), true);
assert.deepEqual(listCredentialEmails(env), ['user@example.com']);
});
it('never persists the plaintext password on disk', () => {
const env = mkEnv();
storeCredential('a@b.com', 'PLAINTEXT_SENTINEL', env);
const raw = readFileSync(join(dir, 'creds.json'), 'utf8');
assert.ok(!raw.includes('PLAINTEXT_SENTINEL'), 'ciphertext must not contain the password');
const rec = JSON.parse(raw).records['a@b.com'];
assert.ok(rec.salt && rec.iv && rec.tag && rec.ct, 'has salt/iv/tag/ct');
});
it('produces different ciphertext for the same password (random salt+iv)', () => {
const env = mkEnv();
storeCredential('a@b.com', 'samepw', env);
const ct1 = JSON.parse(readFileSync(join(dir, 'creds.json'), 'utf8')).records['a@b.com'].ct;
storeCredential('a@b.com', 'samepw', env);
const ct2 = JSON.parse(readFileSync(join(dir, 'creds.json'), 'utf8')).records['a@b.com'].ct;
assert.notEqual(ct1, ct2);
assert.equal(getCredential('a@b.com', env), 'samepw');
});
it('returns null for an unknown email', () => {
assert.equal(getCredential('nobody@x.com', mkEnv()), null);
});
it('deletes a stored credential', () => {
const env = mkEnv();
storeCredential('a@b.com', 'pw', env);
assert.equal(deleteCredential('a@b.com', env), true);
assert.equal(hasCredential('a@b.com', env), false);
assert.equal(deleteCredential('a@b.com', env), false, 'second delete is a no-op');
});
});
describe('credential store — tamper / wrong key', () => {
it('throws (fails closed) when decrypting with a different master key', () => {
storeCredential('a@b.com', 'pw', mkEnv('key-one'));
assert.throws(
() => getCredential('a@b.com', { DEVIN_CONNECT_CRED_KEY: 'key-two', DEVIN_CONNECT_CRED_FILE: join(dir, 'creds.json') }),
/unable to authenticate|bad decrypt|auth/i,
);
});
it('throws when the ciphertext is tampered with', () => {
const env = mkEnv();
storeCredential('a@b.com', 'pw', env);
const file = join(dir, 'creds.json');
const store = JSON.parse(readFileSync(file, 'utf8'));
// Flip a hex nibble in the ciphertext.
const ct = store.records['a@b.com'].ct;
store.records['a@b.com'].ct = (ct[0] === 'a' ? 'b' : 'a') + ct.slice(1);
writeFileSync(file, JSON.stringify(store));
assert.throws(() => getCredential('a@b.com', env), /auth|decrypt/i);
});
it('requires email and password to store', () => {
const env = mkEnv();
assert.throws(() => storeCredential('', 'pw', env), /required/);
assert.throws(() => storeCredential('a@b.com', '', env), /required/);
});
// GPT-05 (independent audit, 2026-09-22): a 16-byte GCM tag truncated to 4 bytes
// still decrypted the password, because the read path passed no `authTagLength` and
// node authenticates against whatever tag the file presents (with only a DEP0182
// warning). The tag length is part of this format, not a caller preference.
it('refuses a record whose GCM tag was truncated (fails closed)', () => {
const env = mkEnv();
storeCredential('a@b.com', 'pw', env);
const file = join(dir, 'creds.json');
const store = JSON.parse(readFileSync(file, 'utf8'));
const fullTag = store.records['a@b.com'].tag;
assert.equal(fullTag.length, 32, 'the writer stores the full 16-byte tag as hex');
for (const keep of [8, 16, 24]) { // 4, 8 and 12 bytes
store.records['a@b.com'].tag = fullTag.slice(0, keep);
writeFileSync(file, JSON.stringify(store));
assert.throws(
() => getCredential('a@b.com', env),
/credential record tag is not the 16-byte GCM tag/,
`a ${keep / 2}-byte tag must not be accepted as this format's 16-byte tag`,
);
}
});
it('refuses a record whose tag field is malformed or over-long', () => {
const env = mkEnv();
storeCredential('a@b.com', 'pw', env);
const file = join(dir, 'creds.json');
const store = JSON.parse(readFileSync(file, 'utf8'));
const fullTag = store.records['a@b.com'].tag;
for (const bad of ['', fullTag + 'ab', 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', null]) {
store.records['a@b.com'].tag = bad;
writeFileSync(file, JSON.stringify(store));
assert.throws(() => getCredential('a@b.com', env), /credential record tag is not the 16-byte GCM tag/, `tag ${JSON.stringify(bad)} must be refused`);
}
// Control: the untouched record still decrypts, so the assertions above are about
// the tag and not about the store being unreadable.
store.records['a@b.com'].tag = fullTag;
writeFileSync(file, JSON.stringify(store));
assert.equal(getCredential('a@b.com', env), 'pw');
});
});
describe('credential store — derived key', () => {
it('derives a 32-byte AES-256 key', () => {
const k = __testing.deriveKey('master', Buffer.from('0123456789abcdef'));
assert.equal(k.length, 32);
});
});
describe('credential store — corruption resilience (C6)', () => {
// Seed two real records, then corrupt the file in different ways and assert
// the resilient reader still recovers the intact credentials. A single bad
// byte must NOT wipe the whole fleet's relogin credentials.
function seedTwo(env) {
storeCredential('alice@example.com', 'alice-pw', env);
storeCredential('bob@example.com', 'bob-pw', env);
}
it('recovers complete records from a file truncated mid-write', () => {
const env = mkEnv();
seedTwo(env);
const file = join(dir, 'creds.json');
const raw = readFileSync(file, 'utf8');
// Simulate a crash mid-write: chop the file partway through the 2nd record.
// The truncation leaves an unterminated string + unbalanced braces, so JSON
// repair can't help — per-record salvage (tier 3) recovers alice intact.
const cut = raw.indexOf('bob@example.com') + 30;
writeFileSync(file, raw.slice(0, cut));
// alice's record is complete and must survive; bob's was truncated.
assert.equal(getCredential('alice@example.com', env), 'alice-pw');
// Self-heal: the file is now clean JSON again on re-read.
const healed = JSON.parse(readFileSync(file, 'utf8'));
assert.ok(healed.records['alice@example.com']);
});
it('tier 2: strips a UTF-8 BOM and a trailing comma', () => {
const env = mkEnv();
storeCredential('alice@example.com', 'alice-pw', env);
const file = join(dir, 'creds.json');
const raw = readFileSync(file, 'utf8');
// Prepend a BOM and inject a trailing comma before the final brace.
const broken = '' + raw.replace(/\}\s*\}\s*$/, '},}');
writeFileSync(file, broken);
assert.equal(getCredential('alice@example.com', env), 'alice-pw');
});
it('tier 3: regex-salvages intact records when the JSON wrapper is unrepairable', () => {
const env = mkEnv();
seedTwo(env);
const file = join(dir, 'creds.json');
const raw = readFileSync(file, 'utf8');
// Mangle the wrapper beyond JSON repair (garbage prefix + broken structure)
// but leave both record bodies textually intact.
const broken = 'GARBAGE!!!{{{ not json at all \n' + raw.replace(/^\s*\{/, '');
writeFileSync(file, broken);
// Both records' { salt, iv, tag, ct } blobs are intact → both recover.
assert.equal(getCredential('alice@example.com', env), 'alice-pw');
assert.equal(getCredential('bob@example.com', env), 'bob-pw');
});
it('tier 3: drops only the mangled record, keeps the rest', () => {
const env = mkEnv();
seedTwo(env);
const file = join(dir, 'creds.json');
const raw = readFileSync(file, 'utf8');
// Corrupt bob's ct field (odd-out hex) and break the wrapper so we hit tier 3.
let broken = raw.replace(/("bob@example\.com"\s*:\s*\{[^}]*"ct"\s*:\s*")[0-9a-f]+/, '$1ZZZ_not_hex');
broken = 'noise\n' + broken.replace(/^\s*\{/, '');
writeFileSync(file, broken);
assert.equal(getCredential('alice@example.com', env), 'alice-pw', 'intact record survives');
assert.equal(hasCredential('bob@example.com', env), false, 'mangled record is dropped, not poisoning the store');
});
it('tryRepairJson fixes a trailing comma and tail garbage; returns null when hopeless', () => {
const good = '{ "v": 1, "records": { "a@x.com": { "salt": "aa", "iv": "bb", "tag": "cc", "ct": "dd" } }, }';
const repaired = __testing.tryRepairJson(good);
assert.ok(repaired && repaired.records['a@x.com'], 'trailing comma repaired (tier 2)');
// Trailing garbage after the last brace is dropped.
assert.ok(__testing.tryRepairJson('{ "v": 1, "records": {} } GARBAGE TAIL'), 'tail after last brace dropped');
// No closing brace at all → unrepairable (caller falls through to tier 3).
assert.equal(__testing.tryRepairJson('{ "records": { "a": '), null);
});
it('salvageRecordsByRegex validates hex shape and skips wrapper keys', () => {
const text = `{ "v": 1, "records": {
"good@x.com": { "salt": "aa", "iv": "bb", "tag": "cc", "ct": "dd" },
"bad@x.com": { "salt": "nothex", "iv": "bb", "tag": "cc", "ct": "dd" }
} }`;
const out = __testing.salvageRecordsByRegex(text);
assert.ok(out['good@x.com']);
assert.equal(out['bad@x.com'], undefined, 'non-hex field rejected');
assert.equal(out['records'], undefined, 'wrapper key never treated as a record');
assert.equal(out['v'], undefined);
});
it('an unrecoverable empty file reads as an empty store (no throw)', () => {
const env = mkEnv();
writeFileSync(join(dir, 'creds.json'), 'totally unparseable @@@@ no records here');
assert.deepEqual(listCredentialEmails(env), []);
});
});
describe('S6-F5: a sub-16-char master key does not silently enable the store', () => {
it('reports disabled below the entropy floor (fail-closed with a loud error)', () => {
assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'x' }), false);
assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'short-key' }), false);
assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'f'.repeat(15) }), false);
assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'f'.repeat(16) }), true, 'boundary: 16 chars is the floor');
assert.equal(isCredStoreEnabled(mkEnv()), true, 'the 19-char fixture still enables');
});
});