project-nomad/admin/tests/unit/curated_resource_schema.spec.ts
chriscrosstalk cdf6c00d4c
feat: support gated downloads for self-hosted curated content (#1172)
Adds an optional `auth: 'nomad_app_key'` discriminator to curated manifest
resources so a curated collection tier can carry content we host ourselves,
gated to official release builds. Without this, only Creator Packs could use
the entitlement Worker; curated tier installs always downloaded
unauthenticated.

No behaviour change for any existing manifest entry: absent `auth` means
unauthenticated, exactly as today.

- `auth` declared on both the type and the VineJS validator. It has to be on
  the validator or VineJS strips it silently on fetch, and the gated download
  would then go out with no header and 401 for everyone. A dedicated spec
  guards that regression.
- Gated resources are pinned to their manifest URL (resolveZimDownload skips
  the catalog comparison) and excluded from catalog update checks, so a
  resource-id collision cannot let a third-party mirror overwrite our content.
  Consequence, commented rather than implied: gated content does not
  auto-update; new versions ship via the manifest.
- 401/403 on a download now reports that an official build is required instead
  of a raw axios status, which is what a fork build will hit.
- The pure `isGatedResource` predicate is deliberately split from the
  env-reading header builder: importing `#start/env` into
  zim_download_resolution triggers env validation at import time and breaks its
  unit tests.

Reuses CREATOR_PACKS_APP_KEY rather than minting a second secret — the question
it answers ("is this an official build?") is identical for both content types.

Verified end to end on a test server: `auth` survives validation into the
cached spec, the Bearer header attaches to only the gated resource, the file
lands byte-exact with an installed_resources row and a Kiwix library entry, and
an entry with a gated URL but no `auth` field fails with the intended message.

No catalog entry is included here. Manifests are fetched live from `main`, so a
gated entry must not merge until this ships and is adopted — pre-`auth` builds
strip the field and 401.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:55 +00:00

91 lines
2.8 KiB
TypeScript

import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import vine from '@vinejs/vine'
import { zimCategoriesSpecSchema } from '../../app/validators/curated_collections.js'
/**
* VineJS STRIPS unknown keys rather than rejecting them, so a manifest field that
* is not declared on the validator disappears silently on fetch. For `auth` that
* failure is invisible and expensive: the gated download would go out with no
* Authorization header and 401 for every user.
*
* These tests exist to catch that specific regression, so treat a failure here as
* "the validator lost a field", not "the test is wrong".
*/
function specWithResource(resource: Record<string, unknown>) {
return {
spec_version: '1.0.0',
categories: [
{
name: 'Survival & Preparedness',
slug: 'survival-and-preparedness',
icon: 'IconTent',
description: 'Field references for austere conditions',
language: 'en',
tiers: [
{
name: 'Comprehensive',
slug: 'comprehensive',
description: 'Everything we have',
resources: [resource],
},
],
},
],
}
}
const baseResource = {
id: 'field-manuals',
version: '2026-07',
title: 'US Military Field Manuals',
description: 'Public-domain US military field manuals',
url: 'https://nomad-packs-worker.chris-556.workers.dev/content/field-manuals_2026-07.zim',
size_mb: 2000,
}
test('auth survives manifest validation', async () => {
const validated: any = await vine.validate({
schema: zimCategoriesSpecSchema,
data: specWithResource({ ...baseResource, auth: 'nomad_app_key' }),
})
const resource = validated.categories[0].tiers[0].resources[0]
assert.equal(
resource.auth,
'nomad_app_key',
'auth was stripped by the validator — gated downloads would 401'
)
})
test('a resource without auth validates and reports auth as undefined', async () => {
const validated: any = await vine.validate({
schema: zimCategoriesSpecSchema,
data: specWithResource(baseResource),
})
assert.equal(validated.categories[0].tiers[0].resources[0].auth, undefined)
})
test('an unrecognised auth scheme is rejected rather than silently ignored', async () => {
await assert.rejects(() =>
vine.validate({
schema: zimCategoriesSpecSchema,
data: specWithResource({ ...baseResource, auth: 'something_else' }),
})
)
})
test('auth and type coexist on one resource', async () => {
const validated: any = await vine.validate({
schema: zimCategoriesSpecSchema,
data: specWithResource({ ...baseResource, type: 'zim', auth: 'nomad_app_key' }),
})
const resource = validated.categories[0].tiers[0].resources[0]
assert.equal(resource.type, 'zim')
assert.equal(resource.auth, 'nomad_app_key')
})