diff --git a/layers/registry/server/api/orgs/[owner].get.ts b/layers/registry/server/api/orgs/[owner].get.ts index 8b96c7b5..2b071a7e 100644 --- a/layers/registry/server/api/orgs/[owner].get.ts +++ b/layers/registry/server/api/orgs/[owner].get.ts @@ -168,22 +168,25 @@ export default defineEventHandler(async (event) => { async function loadOrgProfile(event: H3Event, owner: string): Promise { const db = getDB(event) - const [registryResult, ownerRow] = await Promise.all([ - querySkills(event, { - owner, - sort: 'stars', - page: 1, - limit: 200, - officialOwners, - includeDependencies: true, - }), - loadOwner(owner, db), - ]) + // querySkills must gate loadOwner: an owner with no registry skills is a + // garbage path and 404s here, before loadOwner pays a GitHub users API call + // and upserts a permanent sync_status='404' owners row for every crawler + // miss (issue #260). Do not run the two in parallel. + const registryResult = await querySkills(event, { + owner, + sort: 'stars', + page: 1, + limit: 200, + officialOwners, + includeDependencies: true, + }) if (registryResult.items.length === 0) { throw createError({ statusCode: 404, message: `No skills found for @${owner}` }) } + const ownerRow = await loadOwner(owner, db) + const manifestKind = kindByOwner.get(owner) const kind: OrgKind = manifestKind ?? (ownerRow?.kind === 'user' ? 'user' : 'org') diff --git a/test/unit/org-profile-garbage-owner.test.ts b/test/unit/org-profile-garbage-owner.test.ts new file mode 100644 index 00000000..517d872b --- /dev/null +++ b/test/unit/org-profile-garbage-owner.test.ts @@ -0,0 +1,64 @@ +import type { H3Event } from 'h3' +import type { SqliteD1 } from './helpers/d1-sqlite' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +// Crawler-invented owner paths used to 404 correctly but still paid one +// unauthenticated GitHub users API call per miss and upserted a permanent +// owners row with sync_status='404' for every garbage path (issue #260). +// The registry query gates the owner load, so a path with no skills must +// 404 before either side effect happens. +describe('org profile garbage owner', () => { + const owner = 'not-a-real-owner-xyz' + let harness: SqliteD1 + let githubFetch: ReturnType + let handler: (event: H3Event) => Promise + let cacheMap: Map + + vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) + vi.stubGlobal('createError', (input: Record) => Object.assign(new Error(String(input.message)), input)) + vi.stubGlobal('getRouterParam', (_event: unknown, key: string) => (key === 'owner' ? owner : undefined)) + vi.stubGlobal('$fetch', async () => { + throw new Error('ungh.cc must not be reached for a garbage owner') + }) + + beforeEach(async () => { + vi.resetModules() + harness = createSqliteD1(allMigrations()) + + githubFetch = vi.fn(async () => { + throw new Error('GitHub users API must not be reached for a garbage owner') + }) + vi.stubGlobal('fetch', githubFetch) + + cacheMap = new Map() + vi.stubGlobal('useStorage', () => ({ + getItem: async (key: string) => cacheMap.get(key) ?? null, + setItem: async (key: string, value: unknown) => { + cacheMap.set(key, value) + }, + })) + + handler = (await import('../../layers/registry/server/api/orgs/[owner].get')).default + }) + + it('404s without a GitHub fetch or an owners row', async () => { + await expect(handler(event())).rejects.toMatchObject({ statusCode: 404 }) + + expect(githubFetch).not.toHaveBeenCalled() + + const row = harness.raw.prepare('SELECT owner FROM owners WHERE owner = ?').get(owner) + expect(row).toBeUndefined() + expect(cacheMap.size).toBe(0) + }) + + function event(): H3Event { + return { + context: { + platform: { db: harness.db }, + cloudflare: { context: { waitUntil: () => {} } }, + }, + node: { req: { headers: {} } }, + } as unknown as H3Event + } +})