Skip to content
This repository was archived by the owner on Jul 30, 2026. It is now read-only.

Commit 2cabf2a

Browse files
committed
test: add vitest coverage for install.sh and collect functions
1 parent dc84062 commit 2cabf2a

9 files changed

Lines changed: 6927 additions & 8623 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ jobs:
2626
- run: pnpm typecheck:functions
2727
- run: pnpm lint
2828
- run: pnpm format:check
29+
- run: pnpm test:run
2930
- run: pnpm build

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules
22
dist
33
.astro
44
claude-design
5+
pnpm-lock.yaml

functions/api/collect.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { onRequestPost } from './collect'
3+
4+
const VALID_ID = 'G-1Z7TF66B8X'
5+
6+
type Captured = { url: URL; init: RequestInit }
7+
8+
// Drives onRequestPost with a stubbed global fetch, awaiting the fire-and-forget
9+
// upstream call (captured through waitUntil) so it has settled before assertions.
10+
async function invoke(opts: {
11+
query: string
12+
body: string
13+
env?: { MODREX_GA_MEASUREMENT_ID?: string }
14+
connectingIp?: string
15+
}) {
16+
const captured: Captured[] = []
17+
const fetchMock = vi.fn(async (input: string | URL, init: RequestInit) => {
18+
captured.push({ url: new URL(String(input)), init })
19+
return new Response(null, { status: 204 })
20+
})
21+
vi.stubGlobal('fetch', fetchMock)
22+
23+
const headers = new Headers()
24+
if (opts.connectingIp) headers.set('CF-Connecting-IP', opts.connectingIp)
25+
const request = new Request(`https://modrex.net/api/collect${opts.query}`, {
26+
method: 'POST',
27+
headers,
28+
body: opts.body,
29+
})
30+
31+
const pending: Promise<unknown>[] = []
32+
const res = await onRequestPost({
33+
request,
34+
env: opts.env ?? {},
35+
waitUntil: (p) => pending.push(p),
36+
})
37+
await Promise.all(pending)
38+
return { res, captured, fetchMock }
39+
}
40+
41+
afterEach(() => {
42+
vi.unstubAllGlobals()
43+
})
44+
45+
describe('onRequestPost validation', () => {
46+
it('returns 400 when measurement_id or api_secret is missing', async () => {
47+
const a = await invoke({ query: '?api_secret=s', body: '{}' })
48+
expect(a.res.status).toBe(400)
49+
50+
const b = await invoke({ query: `?measurement_id=${VALID_ID}`, body: '{}' })
51+
expect(b.res.status).toBe(400)
52+
})
53+
54+
it('returns 403 when the id does not match the pinned env id', async () => {
55+
const { res, fetchMock } = await invoke({
56+
query: `?measurement_id=G-WRONG123&api_secret=s`,
57+
body: '{}',
58+
env: { MODREX_GA_MEASUREMENT_ID: VALID_ID },
59+
})
60+
expect(res.status).toBe(403)
61+
expect(fetchMock).not.toHaveBeenCalled()
62+
})
63+
64+
it('accepts the exact pinned id', async () => {
65+
const { res, captured } = await invoke({
66+
query: `?measurement_id=${VALID_ID}&api_secret=s`,
67+
body: '{}',
68+
env: { MODREX_GA_MEASUREMENT_ID: VALID_ID },
69+
})
70+
expect(res.status).toBe(204)
71+
expect(captured).toHaveLength(1)
72+
})
73+
74+
it('falls back to a GA4 id shape check when no env id is pinned', async () => {
75+
const ok = await invoke({ query: `?measurement_id=${VALID_ID}&api_secret=s`, body: '{}' })
76+
expect(ok.res.status).toBe(204)
77+
78+
const bad = await invoke({ query: `?measurement_id=not-a-ga-id&api_secret=s`, body: '{}' })
79+
expect(bad.res.status).toBe(403)
80+
})
81+
})
82+
83+
describe('onRequestPost forwarding', () => {
84+
it('forwards to GA4 mp/collect carrying both credentials', async () => {
85+
const { captured } = await invoke({
86+
query: `?measurement_id=${VALID_ID}&api_secret=secret`,
87+
body: '{}',
88+
})
89+
const { url, init } = captured[0]
90+
expect(url.origin + url.pathname).toBe('https://www.google-analytics.com/mp/collect')
91+
expect(url.searchParams.get('measurement_id')).toBe(VALID_ID)
92+
expect(url.searchParams.get('api_secret')).toBe('secret')
93+
expect(init.method).toBe('POST')
94+
})
95+
96+
it('injects the real client IP as ip_override into a JSON body', async () => {
97+
const { captured } = await invoke({
98+
query: `?measurement_id=${VALID_ID}&api_secret=s`,
99+
body: JSON.stringify({ client_id: '123', events: [] }),
100+
connectingIp: '203.0.113.7',
101+
})
102+
const forwarded = JSON.parse(String(captured[0].init.body))
103+
expect(forwarded.ip_override).toBe('203.0.113.7')
104+
expect(forwarded.client_id).toBe('123')
105+
})
106+
107+
it('does not add ip_override when the edge did not set CF-Connecting-IP', async () => {
108+
const { captured } = await invoke({
109+
query: `?measurement_id=${VALID_ID}&api_secret=s`,
110+
body: JSON.stringify({ client_id: '123' }),
111+
})
112+
expect(JSON.parse(String(captured[0].init.body))).not.toHaveProperty('ip_override')
113+
})
114+
115+
it('forwards a malformed body verbatim instead of throwing', async () => {
116+
const { res, captured } = await invoke({
117+
query: `?measurement_id=${VALID_ID}&api_secret=s`,
118+
body: 'not json',
119+
connectingIp: '203.0.113.7',
120+
})
121+
expect(res.status).toBe(204)
122+
expect(String(captured[0].init.body)).toBe('not json')
123+
})
124+
})

functions/install.sh.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import {
3+
buildPrelude,
4+
parseSemver,
5+
resolveEngineTag,
6+
shellQuote,
7+
type InstallConfig,
8+
} from './install.sh'
9+
10+
describe('shellQuote', () => {
11+
it('wraps a plain value in single quotes', () => {
12+
expect(shellQuote('modrex')).toBe("'modrex'")
13+
})
14+
15+
it('escapes embedded single quotes so a value cannot break out into shell code', () => {
16+
// The injection barrier: close the quote, emit an escaped quote, reopen.
17+
expect(shellQuote("a'b")).toBe(`'a'"'"'b'`)
18+
expect(shellQuote("'; rm -rf / #")).toBe(`''"'"'; rm -rf / #'`)
19+
})
20+
21+
it('renders null and undefined as an empty quoted string', () => {
22+
expect(shellQuote(null)).toBe("''")
23+
expect(shellQuote(undefined)).toBe("''")
24+
})
25+
26+
it('stringifies non-string scalars', () => {
27+
expect(shellQuote(0)).toBe("'0'")
28+
expect(shellQuote(true)).toBe("'true'")
29+
})
30+
})
31+
32+
describe('parseSemver', () => {
33+
it('parses a well-formed vMAJOR.MINOR.PATCH tag', () => {
34+
expect(parseSemver('v1.2.3')).toEqual({ major: 1, minor: 2, patch: 3 })
35+
})
36+
37+
it('rejects tags that are not exactly three numeric segments with a v prefix', () => {
38+
expect(parseSemver('v1')).toBeNull()
39+
expect(parseSemver('v1.2')).toBeNull()
40+
expect(parseSemver('1.2.3')).toBeNull()
41+
expect(parseSemver('v1.2.3-rc1')).toBeNull()
42+
expect(parseSemver('latest')).toBeNull()
43+
})
44+
})
45+
46+
describe('resolveEngineTag', () => {
47+
afterEach(() => {
48+
vi.unstubAllGlobals()
49+
})
50+
51+
function stubTags(names: string[]) {
52+
const fetchMock = vi.fn(
53+
async () => new Response(JSON.stringify(names.map((name) => ({ name }))))
54+
)
55+
vi.stubGlobal('fetch', fetchMock)
56+
return fetchMock
57+
}
58+
59+
it('returns an exact tag without hitting the API', async () => {
60+
const fetchMock = stubTags([])
61+
expect(await resolveEngineTag('v1.1.0')).toBe('v1.1.0')
62+
expect(fetchMock).not.toHaveBeenCalled()
63+
})
64+
65+
it('resolves a bare major to the highest matching minor/patch', async () => {
66+
stubTags(['v1.0.0', 'v1.2.0', 'v1.1.9', 'v2.0.0'])
67+
expect(await resolveEngineTag('v1')).toBe('v1.2.0')
68+
})
69+
70+
it('never crosses into a higher major for a bare-major pin', async () => {
71+
stubTags(['v1.4.0', 'v2.0.0', 'v3.1.0'])
72+
expect(await resolveEngineTag('v1')).toBe('v1.4.0')
73+
})
74+
75+
it('resolves "latest" to the highest semver across all majors', async () => {
76+
stubTags(['v1.9.0', 'v2.0.1', 'v2.0.0'])
77+
expect(await resolveEngineTag('latest')).toBe('v2.0.1')
78+
})
79+
80+
it('throws on an unparseable pin', async () => {
81+
stubTags(['v1.0.0'])
82+
await expect(resolveEngineTag('garbage')).rejects.toThrow('invalid ENGINE_PIN')
83+
})
84+
85+
it('throws when no tag matches the requested major', async () => {
86+
stubTags(['v2.0.0', 'v3.0.0'])
87+
await expect(resolveEngineTag('v1')).rejects.toThrow('no tags found')
88+
})
89+
})
90+
91+
describe('buildPrelude', () => {
92+
const config: InstallConfig = {
93+
schema_version: 1,
94+
project_name: 'modrex',
95+
manifest_url: 'https://example.com/manifest.json',
96+
install_dir: '/opt/modrex',
97+
preferred_variant: { linux: 'appimage', macos: 'dmg' },
98+
}
99+
100+
it('emits uppercased CFG_ exports for every config key', () => {
101+
const prelude = buildPrelude(config)
102+
expect(prelude).toContain("CFG_PROJECT_NAME='modrex'")
103+
expect(prelude).toContain("CFG_INSTALL_DIR='/opt/modrex'")
104+
expect(prelude).toContain("CFG_SCHEMA_VERSION='1'")
105+
})
106+
107+
it('flattens preferred_variant into space-separated os:variant pairs', () => {
108+
expect(buildPrelude(config)).toContain("CFG_PREFERRED_VARIANT='linux:appimage macos:dmg'")
109+
})
110+
111+
it('defaults add_to_path to true and omitted strings to empty', () => {
112+
const prelude = buildPrelude(config)
113+
expect(prelude).toContain("CFG_ADD_TO_PATH='true'")
114+
expect(prelude).toContain("CFG_PUBKEY=''")
115+
})
116+
117+
it('quotes a malicious config value instead of letting it become shell code', () => {
118+
const prelude = buildPrelude({ ...config, project_name: "x'; rm -rf / #" })
119+
expect(prelude).toContain(`CFG_PROJECT_NAME='x'"'"'; rm -rf / #'`)
120+
})
121+
})

functions/install.sh.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ interface GhTag {
2929
// GitHub rejects unauthenticated API requests with no User-Agent.
3030
const GH_API_HEADERS = { 'User-Agent': 'modrex-install-worker' }
3131

32-
function parseSemver(tag: string): { major: number; minor: number; patch: number } | null {
32+
export function parseSemver(tag: string): { major: number; minor: number; patch: number } | null {
3333
const m = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/)
3434
return m ? { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) } : null
3535
}
@@ -42,7 +42,7 @@ async function fetchTags(): Promise<GhTag[]> {
4242
return res.json()
4343
}
4444

45-
async function resolveEngineTag(pin: string): Promise<string> {
45+
export async function resolveEngineTag(pin: string): Promise<string> {
4646
if (parseSemver(pin)) return pin // exact tag, no API call needed
4747

4848
const majorMatch = pin.match(/^v(\d+)$/)
@@ -61,7 +61,7 @@ async function resolveEngineTag(pin: string): Promise<string> {
6161
return `v${best.major}.${best.minor}.${best.patch}`
6262
}
6363

64-
interface InstallConfig {
64+
export interface InstallConfig {
6565
schema_version: number
6666
project_name: string
6767
github_repo?: string
@@ -85,7 +85,7 @@ interface InstallConfig {
8585
// exactly how a config value turns into unintended shell code — see mget's
8686
// README, "Worker integration" — so this is the one thing here that must not
8787
// be simplified away.
88-
function shellQuote(value: unknown): string {
88+
export function shellQuote(value: unknown): string {
8989
return `'${String(value ?? '').replaceAll("'", `'"'"'`)}'`
9090
}
9191

@@ -95,7 +95,7 @@ function flattenPreferredVariant(variant: Record<string, string> | undefined): s
9595
.join(' ')
9696
}
9797

98-
function buildPrelude(config: InstallConfig): string {
98+
export function buildPrelude(config: InstallConfig): string {
9999
const flat: Record<string, unknown> = {
100100
schema_version: config.schema_version,
101101
project_name: config.project_name,

functions/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
"target": "ES2022",
1010
"skipLibCheck": true
1111
},
12-
"include": ["**/*.ts"]
12+
"include": ["**/*.ts"],
13+
"exclude": ["**/*.test.ts"]
1314
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"preview": "astro preview",
99
"typecheck": "astro check",
1010
"typecheck:functions": "tsc -p functions/tsconfig.json",
11+
"test": "vitest",
12+
"test:run": "vitest run",
1113
"lint": "eslint src/",
1214
"lint:fix": "eslint src/ --fix",
1315
"format": "prettier --write .",
@@ -42,6 +44,7 @@
4244
"tailwindcss": "^4.3.0",
4345
"typescript": "^6.0.3",
4446
"typescript-eslint": "^8.60.1",
47+
"vitest": "^4.1.10",
4548
"zod": "^4.4.3"
4649
},
4750
"dependencies": {

0 commit comments

Comments
 (0)