An SEO-less site's robots.txt, sitemap.xml and llms.txt keep reaching the app during an outage longer than the cache TTL; a backend that never answered still gets the 503. quick-261104-xae, review WR-04.
184 lines
8.1 KiB
TypeScript
184 lines
8.1 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test, { type TestContext } from 'node:test'
|
|
import { clearSiteCache, rootFileTargets, siteAnswer, siteState, SITE_CACHE_TTL_MS, type SeoSiteState } from '../server/lib/site.ts'
|
|
import { seoBackendBase, seoServerConfig } from '../server/lib/config.ts'
|
|
|
|
const B = 'http://127.0.0.1:8096'
|
|
const A = 'http://127.0.0.1:8096/_seo/api/v1'
|
|
const KEY = 'a1b2c3d4e5f60718293a4b5c6d7e8f90.txt'
|
|
const ok: SeoSiteState = { state: 'ok', site: { root_files: ['google1234.html', KEY, 'BingSiteAuth.xml'] } }
|
|
|
|
test('rootFileTargets: robots.txt, sitemap.xml, llms.txt and sitemap files go to the backend', () => {
|
|
assert.deepEqual(rootFileTargets('/robots.txt', ok, B, A), [{ kind: 'root', url: `${B}/robots.txt` }])
|
|
assert.deepEqual(rootFileTargets('/sitemap.xml?x=1', ok, B, A), [{ kind: 'root', url: `${B}/sitemap.xml` }])
|
|
assert.deepEqual(rootFileTargets('/llms.txt', ok, B, A), [{ kind: 'root', url: `${B}/llms.txt` }])
|
|
assert.deepEqual(rootFileTargets('/sitemap-posts-1.xml', ok, B, A), [{ kind: 'sitemap', url: `${B}/_sitemap/files/sitemap-posts-1.xml` }])
|
|
})
|
|
|
|
test('rootFileTargets: an older backend (absent) passes every path through, as in v0.1.1', () => {
|
|
const absent: SeoSiteState = { state: 'absent' }
|
|
for (const p of ['/robots.txt', '/sitemap.xml', '/llms.txt', '/sitemap-posts-1.xml', '/google1234.html']) {
|
|
assert.equal(rootFileTargets(p, absent, B, A), null, p)
|
|
}
|
|
})
|
|
|
|
test('rootFileTargets: a backend never reached since start (unknown) still owns the fixed root files (L4 WR-08)', () => {
|
|
// The fetch then fails and the middleware answers 503 with Retry-After,
|
|
// so a crawler never reads the app's 404 as an allow-all robots.txt.
|
|
const unknown: SeoSiteState = { state: 'unknown' }
|
|
assert.deepEqual(rootFileTargets('/robots.txt', unknown, B, A), [{ kind: 'root', url: `${B}/robots.txt` }])
|
|
assert.deepEqual(rootFileTargets('/sitemap.xml?x=1', unknown, B, A), [{ kind: 'root', url: `${B}/sitemap.xml` }])
|
|
assert.deepEqual(rootFileTargets('/llms.txt', unknown, B, A), [{ kind: 'root', url: `${B}/llms.txt` }])
|
|
// sitemap files and verification files still need a site answer
|
|
for (const p of ['/sitemap-posts-1.xml', '/google1234.html', `/${KEY}`, '/random.txt']) {
|
|
assert.equal(rootFileTargets(p, unknown, B, A), null, p)
|
|
}
|
|
})
|
|
|
|
test('rootFileTargets: listed root files only, verification first then the IndexNow key', () => {
|
|
assert.deepEqual(rootFileTargets('/google1234.html', ok, B, A), [{ kind: 'verification', url: `${A}/verification/google1234.html` }])
|
|
assert.deepEqual(rootFileTargets(`/${KEY}`, ok, B, A), [
|
|
{ kind: 'verification', url: `${A}/verification/${KEY}` },
|
|
{ kind: 'indexnow', url: `${B}/_sitemap/indexnow/${KEY}` },
|
|
])
|
|
for (const p of ['/random.txt', '/kontakt', '/google1234.html/x', '/a/google1234.html', '/GOOGLE1234.HTML']) {
|
|
assert.equal(rootFileTargets(p, ok, B, A), null, p)
|
|
}
|
|
assert.equal(rootFileTargets('/google1234.html', { state: 'unknown' }, B, A), null)
|
|
})
|
|
|
|
test('rootFileTargets: no request text beyond an allow-listed name reaches the URL', () => {
|
|
for (const p of ['/sitemap-../../x.xml', '/sitemap-%2e%2e.xml', '/robots.txt/../admin', '//evil.test/robots.txt', '/sitemap-A.xml']) {
|
|
assert.equal(rootFileTargets(p, ok, B, A), null, p)
|
|
}
|
|
})
|
|
|
|
test('siteState: 200 object ok, 404 absent, anything else unknown', () => {
|
|
assert.equal(siteState(200, { root_files: [] }).state, 'ok')
|
|
assert.equal(siteState(404, { error: 'not_found' }).state, 'absent')
|
|
for (const [s, b] of [[500, {}], [200, 'x'], [200, null], [null, null], [200, []]] as const) assert.equal(siteState(s, b).state, 'unknown')
|
|
})
|
|
|
|
test('seoBackendBase: explicit, derived from apiBase, or its origin', () => {
|
|
assert.equal(seoBackendBase('http://go:9000/', A), 'http://go:9000')
|
|
assert.equal(seoBackendBase('', A), B)
|
|
assert.equal(seoBackendBase('', 'http://go:9000/_seo/api/v1/'), 'http://go:9000')
|
|
assert.equal(seoBackendBase('', 'http://go:9000/seo'), 'http://go:9000')
|
|
assert.equal(seoBackendBase('', 'not a url'), '')
|
|
})
|
|
|
|
test('seoServerConfig: defaults, aliases and tokens', () => {
|
|
const d = seoServerConfig({})
|
|
assert.equal(d.enabled, true)
|
|
assert.equal(d.apiBase, A)
|
|
assert.equal(d.backendBase, B)
|
|
assert.equal(d.timeoutMs, 1500)
|
|
assert.equal(d.resolveCacheTtl, 60)
|
|
assert.equal(d.rootFiles, true)
|
|
assert.deepEqual(d.resolveFiles, ['pdf'])
|
|
assert.equal(seoServerConfig({ public: { seo: { enabled: false } } }).enabled, false)
|
|
assert.equal(seoServerConfig({ public: { seo: { cms: 'false' } } }).enabled, false)
|
|
assert.equal(seoServerConfig({ public: { seo: { rootFiles: false } } }).rootFiles, false)
|
|
assert.equal(seoServerConfig({ seo: { resolveCacheTtl: '0' } }).resolveCacheTtl, 0)
|
|
assert.equal(seoServerConfig({ seo: { timeoutMs: '2500' } }).timeoutMs, 2500)
|
|
assert.equal(seoServerConfig({ seo: { notfoundToken: 't' } }).routeReportToken, 't')
|
|
assert.equal(seoServerConfig({ seo: { notfoundToken: 't', routeReportToken: 'r' } }).routeReportToken, 'r')
|
|
})
|
|
|
|
// siteAnswer with a stubbed Nitro $fetch and a moved clock. Each answer is
|
|
// a {status, _data} pair or an Error the fetch throws.
|
|
type Reply = { status: number; _data: unknown } | Error
|
|
|
|
function stubSite(t: TestContext) {
|
|
const g = globalThis as Record<string, unknown>
|
|
const prev = g.$fetch
|
|
const replies: Reply[] = []
|
|
const state = { calls: 0, clock: 1_000_000 }
|
|
g.$fetch = {
|
|
raw: async () => {
|
|
state.calls++
|
|
const r = replies.shift()
|
|
if (!r) throw new Error('no reply queued')
|
|
if (r instanceof Error) throw r
|
|
return r
|
|
},
|
|
}
|
|
t.mock.method(Date, 'now', () => state.clock)
|
|
clearSiteCache()
|
|
const ask = async (r: Reply): Promise<SeoSiteState> => {
|
|
replies.push(r)
|
|
return siteAnswer(seoServerConfig({}), {})
|
|
}
|
|
const expire = () => {
|
|
state.clock += SITE_CACHE_TTL_MS + 1
|
|
}
|
|
const restore = () => {
|
|
clearSiteCache()
|
|
g.$fetch = prev
|
|
}
|
|
return { state, ask, expire, restore }
|
|
}
|
|
|
|
const down = () => new Error('connect ECONNREFUSED')
|
|
|
|
test('siteAnswer: a remembered absent outlives the cache while the backend is down (WR-04)', async (t) => {
|
|
const s = stubSite(t)
|
|
try {
|
|
assert.equal((await s.ask({ status: 404, _data: { error: 'not_found' } })).state, 'absent')
|
|
s.expire()
|
|
for (const r of [down(), { status: 500, _data: {} }, { status: 200, _data: '<html>' }, { status: 401, _data: {} }] as Reply[]) {
|
|
const before = s.state.calls
|
|
const got = await s.ask(r)
|
|
assert.equal(got.state, 'absent', String(r instanceof Error ? r.message : r.status))
|
|
assert.equal(s.state.calls, before + 1, 'each call fetches again')
|
|
for (const p of ['/robots.txt', '/sitemap.xml', '/llms.txt']) assert.equal(rootFileTargets(p, got, B, A), null, p)
|
|
}
|
|
} finally {
|
|
s.restore()
|
|
}
|
|
})
|
|
|
|
test('siteAnswer: the latest definite answer wins (WR-04)', async (t) => {
|
|
const s = stubSite(t)
|
|
try {
|
|
assert.equal((await s.ask({ status: 200, _data: { root_files: [] } })).state, 'ok')
|
|
s.expire()
|
|
assert.equal((await s.ask({ status: 404, _data: {} })).state, 'absent')
|
|
s.expire()
|
|
assert.equal((await s.ask(down())).state, 'absent')
|
|
s.expire()
|
|
assert.equal((await s.ask({ status: 200, _data: { root_files: [] } })).state, 'ok')
|
|
s.expire()
|
|
const got = await s.ask(down())
|
|
assert.equal(got.state, 'ok')
|
|
assert.deepEqual(rootFileTargets('/robots.txt', got, B, A), [{ kind: 'root', url: `${B}/robots.txt` }])
|
|
} finally {
|
|
s.restore()
|
|
}
|
|
})
|
|
|
|
test('siteAnswer: a backend that never answered stays unknown and owns the fixed root files', async (t) => {
|
|
const s = stubSite(t)
|
|
try {
|
|
const got = await s.ask(down())
|
|
assert.equal(got.state, 'unknown')
|
|
assert.deepEqual(rootFileTargets('/robots.txt', got, B, A), [{ kind: 'root', url: `${B}/robots.txt` }])
|
|
const before = s.state.calls
|
|
assert.equal((await s.ask(down())).state, 'unknown')
|
|
assert.equal(s.state.calls, before + 1, 'unknown is never cached')
|
|
} finally {
|
|
s.restore()
|
|
}
|
|
})
|
|
|
|
test('siteAnswer: a purge forgets the remembered answer', async (t) => {
|
|
const s = stubSite(t)
|
|
try {
|
|
assert.equal((await s.ask({ status: 404, _data: {} })).state, 'absent')
|
|
clearSiteCache()
|
|
assert.equal((await s.ask(down())).state, 'unknown')
|
|
} finally {
|
|
s.restore()
|
|
}
|
|
})
|