chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
checkForUpdates,
|
||||
compareStrictSemVer,
|
||||
GOODBUDDY_LATEST_RELEASE_API_URL
|
||||
} from './version-checker'
|
||||
|
||||
const latestVersion = '1.2.3'
|
||||
const manifestUrl =
|
||||
'https://github.com/mesalogo/goodbuddy/releases/download/' +
|
||||
`v${latestVersion}/release-manifest.json`
|
||||
const manifestAssetApiUrl =
|
||||
'https://api.github.com/repos/mesalogo/goodbuddy/releases/assets/123'
|
||||
const releaseAssetUrl =
|
||||
'https://release-assets.githubusercontent.com/github-production-release-asset/' +
|
||||
'123/release-manifest.json?download=1'
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: `GoodBuddy-${latestVersion}-windows-x64-setup.exe`,
|
||||
size: 101,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: `GoodBuddy-${latestVersion}-windows-x64-portable.exe`,
|
||||
size: 102,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
]
|
||||
|
||||
function releasePayload(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
tag_name: `v${latestVersion}`,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: 'release-manifest.json',
|
||||
url: manifestAssetApiUrl,
|
||||
browser_download_url: manifestUrl
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function manifestPayload(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
formatVersion: 1,
|
||||
productName: 'GoodBuddy',
|
||||
version: latestVersion,
|
||||
targets: [
|
||||
{
|
||||
platform: 'windows',
|
||||
arch: 'x64',
|
||||
formats: ['nsis', 'portable'],
|
||||
manifest: 'release-manifest-windows-x64.json',
|
||||
files
|
||||
}
|
||||
],
|
||||
files: files.map((file) => ({
|
||||
platform: 'windows',
|
||||
arch: 'x64',
|
||||
...file
|
||||
})),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(
|
||||
value: unknown,
|
||||
init: ResponseInit = {}
|
||||
): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
function successfulFetch(): ReturnType<typeof vi.fn<typeof fetch>> {
|
||||
return vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input)
|
||||
if (url === GOODBUDDY_LATEST_RELEASE_API_URL) {
|
||||
return jsonResponse(releasePayload())
|
||||
}
|
||||
if (url === manifestAssetApiUrl) {
|
||||
return jsonResponse(manifestPayload())
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
}
|
||||
|
||||
describe('compareStrictSemVer', () => {
|
||||
it('implements SemVer precedence without treating build metadata as newer', () => {
|
||||
expect(compareStrictSemVer('1.0.0-alpha.2', '1.0.0-alpha.10')).toBe(-1)
|
||||
expect(compareStrictSemVer('1.0.0-rc.1', '1.0.0')).toBe(-1)
|
||||
expect(compareStrictSemVer('1.0.0+build.2', '1.0.0+build.1')).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'v1.2.3',
|
||||
'1.2',
|
||||
'01.2.3',
|
||||
'1.2.3-01',
|
||||
'1.2.3-',
|
||||
'1.2.3+'
|
||||
])('rejects non-strict version %s', (version) => {
|
||||
expect(() => compareStrictSemVer(version, '1.0.0')).toThrow(
|
||||
'Invalid semantic version'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkForUpdates', () => {
|
||||
it('uses only the official latest release and canonical manifest URLs', async () => {
|
||||
const transport = successfulFetch()
|
||||
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
updateAvailable: true,
|
||||
currentVersion: '1.0.0',
|
||||
latestVersion,
|
||||
releaseUrl:
|
||||
`https://github.com/mesalogo/goodbuddy/releases/tag/v${latestVersion}`,
|
||||
target: {
|
||||
platform: 'windows',
|
||||
arch: 'x64',
|
||||
formats: ['nsis', 'portable'],
|
||||
files
|
||||
}
|
||||
})
|
||||
|
||||
expect(transport).toHaveBeenCalledTimes(2)
|
||||
expect(transport.mock.calls.map(([input]) => String(input))).toEqual([
|
||||
GOODBUDDY_LATEST_RELEASE_API_URL,
|
||||
manifestAssetApiUrl
|
||||
])
|
||||
expect(transport.mock.calls[1]?.[1]?.headers).toMatchObject({
|
||||
Accept: 'application/octet-stream'
|
||||
})
|
||||
for (const [, init] of transport.mock.calls) {
|
||||
expect(init).toMatchObject({
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('follows only the official GitHub release asset redirect', async () => {
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input)
|
||||
if (url === GOODBUDDY_LATEST_RELEASE_API_URL) {
|
||||
return jsonResponse(releasePayload())
|
||||
}
|
||||
if (url === manifestAssetApiUrl) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: releaseAssetUrl }
|
||||
})
|
||||
}
|
||||
if (url === releaseAssetUrl) {
|
||||
return jsonResponse(manifestPayload())
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).resolves.toMatchObject({ latestVersion })
|
||||
expect(transport.mock.calls.map(([input]) => String(input))).toEqual([
|
||||
GOODBUDDY_LATEST_RELEASE_API_URL,
|
||||
manifestAssetApiUrl,
|
||||
releaseAssetUrl
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://release-assets.githubusercontent.com/manifest.json',
|
||||
'https://attacker.invalid/manifest.json',
|
||||
'https://user:password@release-assets.githubusercontent.com/manifest.json'
|
||||
])('rejects untrusted release redirect %s', async (location) => {
|
||||
const transport = vi.fn<typeof fetch>(async (input) =>
|
||||
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
|
||||
? jsonResponse(releasePayload())
|
||||
: new Response(null, {
|
||||
status: 302,
|
||||
headers: { location }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow('not trusted')
|
||||
})
|
||||
|
||||
it('does not consider equal precedence or an older release an update', async () => {
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: successfulFetch(),
|
||||
currentVersion: '1.2.3+local',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).resolves.toMatchObject({ updateAvailable: false })
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: successfulFetch(),
|
||||
currentVersion: '2.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).resolves.toMatchObject({ updateAvailable: false })
|
||||
})
|
||||
|
||||
it('rejects invalid tags, prereleases, and mismatched manifest versions', async () => {
|
||||
const cases: Array<{
|
||||
release: Record<string, unknown>
|
||||
manifest?: Record<string, unknown>
|
||||
}> = [
|
||||
{ release: releasePayload({ tag_name: '1.2.3' }) },
|
||||
{ release: releasePayload({ tag_name: 'v01.2.3' }) },
|
||||
{ release: releasePayload({ prerelease: true }) },
|
||||
{
|
||||
release: releasePayload(),
|
||||
manifest: manifestPayload({ version: '1.2.4' })
|
||||
}
|
||||
]
|
||||
for (const testCase of cases) {
|
||||
const transport = vi.fn<typeof fetch>(async (input) =>
|
||||
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
|
||||
? jsonResponse(testCase.release)
|
||||
: jsonResponse(testCase.manifest ?? manifestPayload())
|
||||
)
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
url: manifestAssetApiUrl,
|
||||
browser_download_url: 'https://attacker.invalid/manifest.json'
|
||||
},
|
||||
{
|
||||
url: 'https://api.github.com/repos/attacker/repo/releases/assets/123',
|
||||
browser_download_url: manifestUrl
|
||||
}
|
||||
])('rejects noncanonical release assets without requesting them', async (asset) => {
|
||||
const transport = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse(
|
||||
releasePayload({
|
||||
assets: [
|
||||
{
|
||||
name: 'release-manifest.json',
|
||||
...asset
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow('canonical aggregate manifest')
|
||||
expect(transport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects missing, duplicate, incomplete, and inconsistent targets', async () => {
|
||||
const invalidManifests = [
|
||||
manifestPayload({ targets: [] }),
|
||||
manifestPayload({
|
||||
targets: [
|
||||
manifestPayload().targets,
|
||||
manifestPayload().targets
|
||||
].flat()
|
||||
}),
|
||||
manifestPayload({
|
||||
targets: [
|
||||
{
|
||||
platform: 'windows',
|
||||
arch: 'x64',
|
||||
formats: ['nsis', 'portable'],
|
||||
manifest: 'release-manifest-windows-x64.json',
|
||||
files: [files[0]]
|
||||
}
|
||||
]
|
||||
}),
|
||||
manifestPayload({ files: [] })
|
||||
]
|
||||
for (const manifest of invalidManifests) {
|
||||
const transport = vi.fn<typeof fetch>(async (input) =>
|
||||
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
|
||||
? jsonResponse(releasePayload())
|
||||
: jsonResponse(manifest)
|
||||
)
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unsafe file metadata and unsupported targets', async () => {
|
||||
const unsafeManifest = manifestPayload({
|
||||
targets: [
|
||||
{
|
||||
platform: 'windows',
|
||||
arch: 'x64',
|
||||
formats: ['nsis', 'portable'],
|
||||
manifest: 'release-manifest-windows-x64.json',
|
||||
files: [
|
||||
{ ...files[0], name: '../GoodBuddy.exe' },
|
||||
files[1]
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const transport = vi.fn<typeof fetch>(async (input) =>
|
||||
String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
|
||||
? jsonResponse(releasePayload())
|
||||
: jsonResponse(unsafeManifest)
|
||||
)
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: successfulFetch(),
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'freebsd',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow('Unsupported update platform')
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: successfulFetch(),
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'ia32'
|
||||
})
|
||||
).rejects.toThrow('Unsupported update architecture')
|
||||
})
|
||||
|
||||
it('rejects HTTP errors, invalid JSON, and bounded oversized bodies', async () => {
|
||||
const failedFetch = vi.fn<typeof fetch>(async () =>
|
||||
new Response('{}', { status: 503 })
|
||||
)
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: failedFetch,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow('HTTP 503')
|
||||
|
||||
const invalidJsonFetch = vi.fn<typeof fetch>(async () =>
|
||||
new Response('{invalid')
|
||||
)
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: invalidJsonFetch,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64'
|
||||
})
|
||||
).rejects.toThrow('not valid JSON')
|
||||
|
||||
const oversizedFetch = vi.fn<typeof fetch>(async () =>
|
||||
new Response('x'.repeat(65), {
|
||||
headers: { 'content-length': '65' }
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: oversizedFetch,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
maxJsonBytes: 64
|
||||
})
|
||||
).rejects.toThrow('too large')
|
||||
})
|
||||
|
||||
it('applies the timeout while reading a stalled response body', async () => {
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input) === GOODBUDDY_LATEST_RELEASE_API_URL) {
|
||||
return jsonResponse(releasePayload())
|
||||
}
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
// Keep the body open without yielding bytes.
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
timeoutMs: 5
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('aborts a request at the configured timeout', async () => {
|
||||
const transport = vi.fn<typeof fetch>(
|
||||
async (_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
new DOMException('The operation was aborted', 'AbortError')
|
||||
),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
checkForUpdates({
|
||||
fetch: transport,
|
||||
currentVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
timeoutMs: 5
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user