fix: enforce VIP content boundary and PATCH updates
This commit is contained in:
@@ -126,7 +126,7 @@ class AgentApi:
|
|||||||
except HTTPError as error:
|
except HTTPError as error:
|
||||||
return error.code, error.read(), error.headers.get("content-type", "")
|
return error.code, error.read(), error.headers.get("content-type", "")
|
||||||
except (TimeoutError, URLError) as error:
|
except (TimeoutError, URLError) as error:
|
||||||
if method in ("POST", "PUT"):
|
if method in ("POST", "PATCH"):
|
||||||
raise AmbiguousWrite(f"agent API {method} outcome is unknown") from error
|
raise AmbiguousWrite(f"agent API {method} outcome is unknown") from error
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ class AgentApi:
|
|||||||
probe = self._probe(body)
|
probe = self._probe(body)
|
||||||
if probe:
|
if probe:
|
||||||
self.body_probes[slug] = probe
|
self.body_probes[slug] = probe
|
||||||
status, raw, _ = self._request("PUT", f"/api/agent/posts/{quote(slug)}", body)
|
status, raw, _ = self._request("PATCH", f"/api/agent/posts/{quote(slug)}", body)
|
||||||
if status != 200:
|
if status != 200:
|
||||||
raise RuntimeError(f"agent API update failed ({status})")
|
raise RuntimeError(f"agent API update failed ({status})")
|
||||||
return self._decode(status, raw)
|
return self._decode(status, raw)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ class PublisherTests(unittest.TestCase):
|
|||||||
self.assertEqual(api.create_calls, [])
|
self.assertEqual(api.create_calls, [])
|
||||||
self.assertEqual(len(api.update_calls), 1)
|
self.assertEqual(len(api.update_calls), 1)
|
||||||
|
|
||||||
def test_republish_puts_existing_slug_and_never_posts(self):
|
def test_republish_updates_existing_slug_and_never_posts(self):
|
||||||
record = {
|
record = {
|
||||||
"id": 7, "title": "Old", "slug": "existing-guide", "status": "published",
|
"id": 7, "title": "Old", "slug": "existing-guide", "status": "published",
|
||||||
"access": "vip", "category": "أدوات", "publishedAt": "2026-10-09T09:00:00Z",
|
"access": "vip", "category": "أدوات", "publishedAt": "2026-10-09T09:00:00Z",
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ class FakeAgentApi(AgentApi):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("test-key")
|
super().__init__("test-key")
|
||||||
self.responses = []
|
self.responses = []
|
||||||
|
self.requests = []
|
||||||
self.public = (200, b"page")
|
self.public = (200, b"page")
|
||||||
self.rsc = (200, b"rsc")
|
self.rsc = (200, b"rsc")
|
||||||
|
|
||||||
def _request(self, method, path, body=None, *, anonymous=False):
|
def _request(self, method, path, body=None, *, anonymous=False):
|
||||||
|
self.requests.append((method, path, body, anonymous))
|
||||||
if self.responses:
|
if self.responses:
|
||||||
return self.responses.pop(0)
|
return self.responses.pop(0)
|
||||||
return 200, b"{}", "application/json"
|
return 200, b"{}", "application/json"
|
||||||
@@ -80,6 +82,12 @@ class RuntimeTests(unittest.TestCase):
|
|||||||
self.assertEqual(api.create(body)["slug"], "guide")
|
self.assertEqual(api.create(body)["slug"], "guide")
|
||||||
self.assertEqual(api.body_probes["guide"], "distinctive protected body sentence")
|
self.assertEqual(api.body_probes["guide"], "distinctive protected body sentence")
|
||||||
|
|
||||||
|
def test_agent_api_updates_with_patch(self):
|
||||||
|
api = FakeAgentApi()
|
||||||
|
api.responses = [(200, json.dumps({"slug": "guide"}).encode(), "application/json")]
|
||||||
|
self.assertEqual(api.update("guide", {"publish": True})["slug"], "guide")
|
||||||
|
self.assertEqual(api.requests[0][0:2], ("PATCH", "/api/agent/posts/guide"))
|
||||||
|
|
||||||
@patch("runtime.time.sleep", return_value=None)
|
@patch("runtime.time.sleep", return_value=None)
|
||||||
def test_vip_verification_rejects_body_leak(self, _sleep):
|
def test_vip_verification_rejects_body_leak(self, _sleep):
|
||||||
api = FakeAgentApi()
|
api = FakeAgentApi()
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ curl -X POST http://localhost:3000/api/agent/posts \
|
|||||||
-d '{"title":"...","slug":"my-post","dek":"...","category":"وكلاء","access":"vip","teaser":"...","readingMinutes":8,"content":{"root":{"type":"root","children":[],"format":"","indent":0,"version":1}}}'
|
-d '{"title":"...","slug":"my-post","dek":"...","category":"وكلاء","access":"vip","teaser":"...","readingMinutes":8,"content":{"root":{"type":"root","children":[],"format":"","indent":0,"version":1}}}'
|
||||||
|
|
||||||
# update (cannot change status)
|
# update (cannot change status)
|
||||||
curl -X PUT http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ...
|
curl -X PATCH http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ...
|
||||||
```
|
```
|
||||||
|
|
||||||
Omitting `cover` assigns the per-category default cover.
|
Omitting `cover` assigns the per-category default cover.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Returns 200 for a draft or published post, 404 when absent. Authentication is re
|
|||||||
|
|
||||||
## Promote or republish
|
## Promote or republish
|
||||||
|
|
||||||
`PUT /api/agent/posts/<slug>`
|
`PATCH /api/agent/posts/<slug>`
|
||||||
|
|
||||||
Draft promotion body:
|
Draft promotion body:
|
||||||
|
|
||||||
|
|||||||
@@ -98,12 +98,12 @@ export default async function PostPage({ params }: Args) {
|
|||||||
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||||
depth: 1,
|
depth: 1,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
// content selected only for entitled viewers — never reaches non-VIP clients
|
// VIP content is deliberately excluded here. It is fetched only after
|
||||||
|
// the viewer's entitlement has been established below.
|
||||||
select: {
|
select: {
|
||||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||||
views: true, likes: true,
|
views: true, likes: true,
|
||||||
readingMinutes: true, publishedAt: true, teaser: true, cover: true,
|
readingMinutes: true, publishedAt: true, teaser: true, cover: true,
|
||||||
content: true,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const post = res.docs[0] as unknown as PostDoc | undefined
|
const post = res.docs[0] as unknown as PostDoc | undefined
|
||||||
@@ -111,6 +111,16 @@ export default async function PostPage({ params }: Args) {
|
|||||||
|
|
||||||
const role = await getViewerRole()
|
const role = await getViewerRole()
|
||||||
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
|
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
|
||||||
|
if (unlocked) {
|
||||||
|
const entitled = await payload.findByID({
|
||||||
|
collection: 'posts',
|
||||||
|
id: post.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
select: { content: true },
|
||||||
|
})
|
||||||
|
post.content = entitled.content
|
||||||
|
}
|
||||||
const accessChip = post.access === 'free' ? 'مقال مجاني · وصول كامل' : 'VIP · وصول كامل'
|
const accessChip = post.access === 'free' ? 'مقال مجاني · وصول كامل' : 'VIP · وصول كامل'
|
||||||
const viewerLevel = await getViewerField('level')
|
const viewerLevel = await getViewerField('level')
|
||||||
const viewerId = await getViewerField('id')
|
const viewerId = await getViewerField('id')
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ slug: strin
|
|||||||
return NextResponse.json(postResponse(post))
|
return NextResponse.json(postResponse(post))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
|
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
|
||||||
if (!validKey(req)) return unauthorized()
|
if (!validKey(req)) return unauthorized()
|
||||||
const { slug } = await ctx.params
|
const { slug } = await ctx.params
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, it } from 'node:test'
|
||||||
|
|
||||||
|
const routeSource = readFileSync(
|
||||||
|
new URL('../src/app/api/agent/posts/[slug]/route.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('agent post update HTTP contract', () => {
|
||||||
|
it('exports PATCH and does not export PUT', () => {
|
||||||
|
assert.match(routeSource, /export async function PATCH\(/)
|
||||||
|
assert.doesNotMatch(routeSource, /export async function PUT\(/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, it } from 'node:test'
|
||||||
|
|
||||||
|
const pageSource = readFileSync(
|
||||||
|
new URL('../src/app/(frontend)/posts/[slug]/page.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
const slug = process.env.ANWARPY_VIP_TEST_SLUG
|
||||||
|
const marker = process.env.ANWARPY_VIP_BODY_MARKER
|
||||||
|
const cookie = process.env.ANWARPY_VIP_COOKIE
|
||||||
|
const baseUrl = (process.env.ANWARPY_TEST_BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '')
|
||||||
|
|
||||||
|
async function body(url, headers = {}) {
|
||||||
|
const response = await fetch(url, { headers, redirect: 'manual' })
|
||||||
|
return { status: response.status, text: await response.text() }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VIP content query boundary', () => {
|
||||||
|
it('does not load content before entitlement is established', () => {
|
||||||
|
const pageStart = pageSource.indexOf('export default async function PostPage')
|
||||||
|
const roleCheck = pageSource.indexOf('const role = await getViewerRole()', pageStart)
|
||||||
|
const beforeRole = pageSource.slice(pageStart, roleCheck)
|
||||||
|
assert.doesNotMatch(beforeRole, /content\s*:\s*true/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads content only inside the unlocked branch', () => {
|
||||||
|
assert.match(
|
||||||
|
pageSource,
|
||||||
|
/const unlocked[\s\S]*if \(unlocked\) \{[\s\S]*findByID\([\s\S]*select:\s*\{\s*content:\s*true\s*\}/,
|
||||||
|
)
|
||||||
|
assert.match(pageSource, /\{unlocked \? \([\s\S]*<RichText data=\{post\.content\}/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('VIP transport acceptance', () => {
|
||||||
|
it('does not expose the protected marker to anonymous HTML, RSC, or REST', {
|
||||||
|
skip: !slug || !marker,
|
||||||
|
}, async () => {
|
||||||
|
const encoded = encodeURIComponent(slug)
|
||||||
|
const [html, rsc, rest] = await Promise.all([
|
||||||
|
body(`${baseUrl}/posts/${encoded}`),
|
||||||
|
body(`${baseUrl}/posts/${encoded}?__rsc=vip-test`, { RSC: '1', Accept: 'text/x-component' }),
|
||||||
|
body(`${baseUrl}/api/posts?where%5Bslug%5D%5Bequals%5D=${encoded}&depth=0`, { Accept: 'application/json' }),
|
||||||
|
])
|
||||||
|
assert.equal(html.status, 200)
|
||||||
|
assert.ok([200, 204].includes(rsc.status))
|
||||||
|
assert.ok([200, 401, 403].includes(rest.status))
|
||||||
|
for (const response of [html, rsc, rest]) {
|
||||||
|
assert.equal(response.text.includes(marker), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes the protected marker to an entitled VIP session', {
|
||||||
|
skip: !slug || !marker || !cookie,
|
||||||
|
}, async () => {
|
||||||
|
const response = await body(`${baseUrl}/posts/${encodeURIComponent(slug)}`, { Cookie: cookie })
|
||||||
|
assert.equal(response.status, 200)
|
||||||
|
assert.equal(response.text.includes(marker), true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user