test(mcp): coupling guard between enforceBasicLoginGate and login (#91)

McpService.enforceBasicLoginGate re-implements AuthController.login's pre-token
SSO/MFA gate; silent drift would re-open the bypass. Add an AST contract test
(comments stripped) asserting BOTH method bodies contain validateSsoEnforcement,
the EE-MFA require, and checkMfaRequirements — so dropping the gate from either
side fails CI. Test-only (no core/auth refactor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 03:49:52 +03:00
parent a2ded7ecfb
commit 7c57a386b2

View File

@@ -0,0 +1,183 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as ts from 'typescript';
/**
* Coupling / drift-guard contract for the pre-token SSO/MFA gate (Gitea #91).
*
* There are TWO independent code paths that must run the SAME pre-token gate
* before any token is minted from a password:
*
* 1) AuthController.login (core/auth/auth.controller.ts) — the normal
* /api/auth/login path. Before issuing a token it runs:
* validateSsoEnforcement(workspace)
* -> lazy require('./../../ee/mfa/services/mfa.service')
* -> mfaService.checkMfaRequirements(...)
*
* 2) McpService.enforceBasicLoginGate (integrations/mcp/mcp.service.ts) —
* the /mcp HTTP-Basic path. It re-implements EXACTLY the same pre-token
* sequence so the Basic path is not an SSO/MFA bypass.
*
* These two implementations are physically separate (no shared helper — Option 1
* would extract one, but that refactor is deliberately skipped in this batch).
* If a future edit drops the SSO check or the MFA check from one side, the two
* paths silently DRIFT and the dropped side re-opens an SSO/MFA bypass. This
* test asserts BOTH method bodies still contain BOTH gate calls, so such a drift
* fails the build.
*
* Why a SOURCE-LEVEL (AST) contract test rather than live instances: neither
* AuthController nor McpService can be constructed — or even imported — under
* this jest config without mocking their heavy transitive graph (the
* @docmost/transactional React-email templates and the lib0/ESM collaboration
* chain that ts-jest's transformIgnorePatterns cannot load). This mirrors the
* existing AST-contract approach in
* core/auth/services/verify-user-credentials.contract.spec.ts: read the real
* source, extract the relevant method bodies, and assert each contains the
* required calls.
*/
// The exact symbols BOTH pre-token paths must share. Drop any of these from one
// side and that side stops enforcing SSO/MFA before minting a token.
const SSO_GATE = 'validateSsoEnforcement';
// The lazy EE-MFA require specifier — byte-for-byte identical in both files (a
// fork WITHOUT the EE module bundled behaves the same on both sides: no module,
// no MFA gate).
const MFA_REQUIRE = "require('./../../ee/mfa/services/mfa.service')";
// The MFA requirement check both paths call on the lazily-loaded service.
const MFA_CHECK = 'checkMfaRequirements';
/**
* Strip all comments from a chunk of TS source, leaving only real CODE tokens.
*
* This is load-bearing: the method bodies we inspect DOCUMENT the gate they run
* (e.g. "// 1) validateSsoEnforcement(workspace) — reject if ..."), so a naive
* substring match on the raw body text would still pass even if the actual call
* were deleted and only the comment survived. We tokenize with the TS scanner
* and re-emit only non-comment token text, so the assertions below see code, not
* prose. (A deleted/commented-out gate call therefore correctly fails the test.)
*/
function stripComments(text: string): string {
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
/* skipTrivia */ false,
ts.LanguageVariant.Standard,
text,
);
let out = '';
let kind = scanner.scan();
while (kind !== ts.SyntaxKind.EndOfFileToken) {
if (
kind !== ts.SyntaxKind.SingleLineCommentTrivia &&
kind !== ts.SyntaxKind.MultiLineCommentTrivia
) {
out += scanner.getTokenText();
} else {
// Preserve a separator so adjacent tokens around a comment don't merge.
out += ' ';
}
kind = scanner.scan();
}
return out;
}
/**
* Return the COMMENT-STRIPPED source text of a named method body (a class
* MethodDeclaration). Throws if the method is not found so a rename can never
* silently make this test vacuous.
*/
function methodBodyText(
source: string,
fileLabel: string,
methodName: string,
): string {
const sf = ts.createSourceFile(
fileLabel,
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
);
let found: string | null = null;
const visit = (node: ts.Node): void => {
if (
ts.isMethodDeclaration(node) &&
node.name &&
ts.isIdentifier(node.name) &&
node.name.text === methodName &&
node.body
) {
found = node.body.getText(sf);
return;
}
ts.forEachChild(node, visit);
};
visit(sf);
if (found === null) {
throw new Error(`method ${methodName} not found in ${fileLabel}`);
}
return stripComments(found);
}
describe('pre-token SSO/MFA gate coupling contract (Gitea #91)', () => {
const controllerPath = path.join(
__dirname,
'..',
'..',
'core',
'auth',
'auth.controller.ts',
);
const mcpServicePath = path.join(__dirname, 'mcp.service.ts');
const controllerSource = fs.readFileSync(controllerPath, 'utf8');
const mcpServiceSource = fs.readFileSync(mcpServicePath, 'utf8');
// The real login pre-token gate lives inline in AuthController.login.
const loginBody = methodBodyText(
controllerSource,
'auth.controller.ts',
'login',
);
// The /mcp Basic-path mirror lives in McpService.enforceBasicLoginGate.
const gateBody = methodBodyText(
mcpServiceSource,
'mcp.service.ts',
'enforceBasicLoginGate',
);
it('AuthController.login runs the full pre-token gate (SSO + MFA)', () => {
expect(loginBody).toContain(SSO_GATE);
expect(loginBody).toContain(MFA_REQUIRE);
expect(loginBody).toContain(MFA_CHECK);
});
it('McpService.enforceBasicLoginGate runs the full pre-token gate (SSO + MFA)', () => {
expect(gateBody).toContain(SSO_GATE);
expect(gateBody).toContain(MFA_REQUIRE);
expect(gateBody).toContain(MFA_CHECK);
});
it('both paths share EVERY gate symbol (no drift between the two)', () => {
// The drift guard: if a future edit drops a gate call from exactly one
// side, that side fails here while the other still passes — pinpointing the
// bypass. Both sides carrying the same set keeps them semantically coupled.
for (const symbol of [SSO_GATE, MFA_REQUIRE, MFA_CHECK]) {
const inLogin = loginBody.includes(symbol);
const inGate = gateBody.includes(symbol);
expect({ symbol, inLogin, inGate }).toEqual({
symbol,
inLogin: true,
inGate: true,
});
}
});
it('the EE-MFA require specifier is byte-for-byte identical on both sides', () => {
// A drift in the require PATH (not just its presence) would load a different
// module on one side — e.g. the controller gating on MFA while the Basic
// path silently requires a non-existent path and skips MFA. Pin the literal.
expect(loginBody).toContain(MFA_REQUIRE);
expect(gateBody).toContain(MFA_REQUIRE);
});
});