2026-09-10 14:15:41 +08:00
import assert from 'node:assert/strict'
import { readFileSync , readdirSync , existsSync } from 'node:fs'
import { resolve , dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
const root = resolve ( dirname ( fileURLToPath ( import . meta . url )), '..' )
const read = path => readFileSync ( resolve ( root , path ), 'utf8' )
// Deliberately limited to the repository's explicit short-form Path routes.
// Unsupported route syntax fails instead of silently skipping coverage.
function routes ( source ) {
return [... source . matchAll ( /^\s*- id:\s*([^\r\n]+)([\s\S]*?)(?=^\s*- id:|^\S|$(?![\s\S]))/gm )]. map (([, id , body ]) => {
const uri = body . match ( /uri:\s*lb:\/\/([\w-]+)/ ) ? .[ 1 ]
const paths = body . match ( /Path=([^\r\n"\]]+)/ ) ? .[ 1 ] ? . trim (). split ( ',' )
assert . ok ( uri && paths ? . length , `Unsupported route: ${ id } ` )
assert . ok ( ! /\b(?:Method|Host|RewritePath|SetPath|PrefixPath)=/ . test ( body ), `Extend checker for conditional/rewrite route: ${ id } ` )
assert . ok ( ! /\border\s*:/ . test ( body ), `Extend checker for explicit route order: ${ id } ` )
return { id : id . trim (), uri , paths }
})
}
function matches ( pattern , path ) {
const parts = pattern . split ( '/' )
const actual = path . split ( '/' )
for ( let i = 0 ; i < parts . length ; i ++ ) {
if ( parts [ i ] === '**' ) return true
if ( actual [ i ] === undefined ) return false
if ( parts [ i ] === '*' || /^\{[^}]+\}$/ . test ( parts [ i ])) continue
if ( parts [ i ] !== actual [ i ]) return false
}
return parts . length === actual . length
}
function javaFiles ( dir ) {
if ( ! existsSync ( dir )) return []
return readdirSync ( dir , { withFileTypes : true }). flatMap ( entry => {
const path = resolve ( dir , entry . name )
return entry . isDirectory () ? javaFiles ( path ) : entry . name . endsWith ( '.java' ) ? [ path ] : []
})
}
function controllerPaths ( source , file ) {
const clean = source . replace ( /\/\*[\s\S]*?\*\//g , '' ). replace ( /^\s*\/\/.*$/gm , '' )
if ( ! /@(?:RestController|Controller)\b/ . test ( clean )) return []
const header = clean . split ( /\bclass\s+/ )[ 0 ]
const mapping = header . match ( /@RequestMapping\s*\(([^)]*)\)/ )
const extract = args => {
for ( const [, name , value ] of clean . matchAll ( /static\s+final\s+String\s+(\w+)\s*=\s*"([^"\r\n]*)"\s*;/g )) {
args = args . replace ( new RegExp ( `\\b ${ name } \\b` , 'g' ), `" ${ value } "` )
}
while ( /"[^"\r\n]*"\s*\+\s*"[^"\r\n]*"/ . test ( args )) {
args = args . replace ( /"([^"\r\n]*)"\s*\+\s*"([^"\r\n]*)"/g , '"$1$2"' )
}
const paths = [... args . matchAll ( /"(\/[^"\r\n]*)"/g )]. map ( m => m [ 1 ])
assert . ok ( paths . length || ! args . trim () || /^(?:produces|consumes|method)\s*=/ . test ( args . trim ()), `Non-literal mapping requires checker support: ${ file } ` )
assert . ok ( ! args . replace ( /"[^"\r\n]*"/g , '' ). includes ( '+' ), `Concatenated mapping requires checker support: ${ file } ` )
return paths . length ? paths : [ '' ]
}
const bases = mapping ? extract ( mapping [ 1 ]) : [ '' ]
const body = clean . slice ( header . length )
const methods = [... body . matchAll ( /@(?:Get|Post|Put|Delete|Patch|Request)Mapping\b(?:\s*\(([^)]*)\))?/g )]
if ( ! methods . length ) return bases
return methods . flatMap (([, args = '' ]) => extract ( args ). flatMap ( path => bases . map ( base => ` ${ base }${ path } ` )))
}
function verify ( path , service , table , file ) {
if ( path . includes ( '{app}' )) {
for ( const app of [ 'platform' , 'merchant' ]) verify ( path . replace ( '{app}' , app ), service , table , file )
return
}
// Internal service-token endpoints must never be forwarded, regardless of owner.
const hit = table . find ( route => route . paths . some ( pattern => matches ( pattern , path )))
if ( path . startsWith ( '/internal/' )) {
assert . equal ( hit , undefined , `Internal endpoint exposed: ${ file } ${ path } ` )
} else if ( path . startsWith ( '/api/' ) || path . startsWith ( '/openapi/' )) {
assert . ok ( hit , `Missing Gateway route: ${ file } ${ path } ` )
assert . equal ( hit . uri , service , `Wrong destination: ${ file } ${ path } via ${ hit . id } ` )
}
}
const domestic = routes ( read ( 'gateway/src/main/resources/application.yml' ))
const modules = [... read ( 'pom.xml' ). matchAll ( /<module>([^<]+)<\/module>/g )]. map ( m => m [ 1 ])
test ( 'local regression and root CI require route checks before Maven/dispatch' , () => {
const local = read ( 'scripts/backend-full-regression.ps1' )
assert . ok ( local . indexOf ( '& node --test' ) < local . indexOf ( '& $MavenCommand' ))
assert . match ( local , /gateway-route-contract\.test\.mjs/ )
const pipeline = read ( 'Jenkinsfile' )
const gate = pipeline . slice ( pipeline . indexOf ( "stage('Gateway 路由静态门禁')" ), pipeline . indexOf ( "stage('统一 Maven 测试门禁')" ))
assert . match ( gate , /node --test scripts\/gateway-route-contract\.test\.mjs/ )
assert . doesNotMatch ( gate , /SKIP_TESTS|catchError|returnStatus|\|\| true/ )
})
test ( 'Controller prefixes route to their owning service; internal prefixes stay private' , () => {
let count = 0
for ( const module of modules ) {
if ( module . startsWith ( 'common-' ) || module . startsWith ( 'gateway' ) || module === 'platform-bom' ) continue
const app = ` ${ module } /src/main/resources/application.yml`
if ( ! existsSync ( resolve ( root , app ))) continue
const service = read ( app ). match ( /application:\s*\r?\n\s+name:\s*([\w-]+)/ ) ? .[ 1 ]
assert . ok ( service , `Missing application name: ${ module } ` )
2026-09-29 17:55:54 +08:00
const tables = [ domestic ]
2026-09-10 14:15:41 +08:00
for ( const file of javaFiles ( resolve ( root , module , 'src/main/java' ))) {
for ( const path of controllerPaths ( readFileSync ( file , 'utf8' ), file )) {
2026-09-20 14:26:03 +08:00
// PDA release metadata lives in the domestic File control plane. Both
// PDA apps use the domestic Gateway for update checks and downloads.
const targetTables = path . startsWith ( '/api/pda-updates/' ) ? [ domestic ] : tables
for ( const table of targetTables ) verify ( path , service , table , file )
2026-09-10 14:15:41 +08:00
count ++
}
}
}
assert . ok ( count > 100 , `Unexpectedly small Controller inventory: ${ count } ` )
})
test ( 'removing container shipping nodes route reproduces the missing registration' , () => {
const broken = domestic . map ( route => ({ ... route , paths : route . paths . filter ( p => p !== '/api/platform/v1/container-shipping-nodes/**' ) }))
assert . throws (() => verify ( '/api/platform/v1/container-shipping-nodes' , 'firstleg-service' , broken , 'fixture' ), /Missing Gateway route/ )
})
test ( 'wrong target and internal exposure are rejected' , () => {
assert . throws (() => verify ( '/api/new' , 'firstleg-service' , [{ id : 'wrong' , uri : 'order-service' , paths : [ '/api/**' ] }], 'fixture' ), /Wrong destination/ )
assert . throws (() => verify ( '/internal/private' , 'firstleg-service' , [{ id : 'broad' , uri : 'firstleg-service' , paths : [ '/**' ] }], 'fixture' ), /Internal endpoint exposed/ )
})
test ( 'path variables, exact paths and segment boundaries are respected' , () => {
assert . ok ( matches ( '/api/items/{id}/**' , '/api/items/123/action' ))
assert . ok ( matches ( '/api/items/**' , '/api/items' ))
assert . ok ( ! matches ( '/api/items/**' , '/api/items-other' ))
assert . ok ( ! matches ( '/api/items' , '/api/items/action' ))
assert . deepEqual ( controllerPaths ( '@RestController @RequestMapping(value = {"/api/a", "/api/b"}) public class Example {}' , 'fixture' ), [ '/api/a' , '/api/b' ])
assert . throws (() => controllerPaths ( '@RestController @RequestMapping(API_PATH) public class Example {}' , 'fixture' ), /Non-literal/ )
assert . deepEqual ( controllerPaths ( '@RestController @RequestMapping("/api") public class Example { @GetMapping("/items") void list() {} }' , 'fixture' ), [ '/api/items' ])
assert . deepEqual ( controllerPaths ( '@RestController public class Example { static final String PATH = "/api/items"; @GetMapping(PATH) void list() {} }' , 'fixture' ), [ '/api/items' ])
assert . throws (() => verify ( '/api/item' , 'firstleg-service' , [{ id : 'shadow' , uri : 'order-service' , paths : [ '/api/**' ] }, { id : 'correct' , uri : 'firstleg-service' , paths : [ '/api/item' ] }], 'fixture' ), /Wrong destination/ )
})