feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
724
server/supabase/tests/content-report-red.e2e.mjs
Normal file
724
server/supabase/tests/content-report-red.e2e.mjs
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const supabaseDirectory = resolve(workspaceRoot, 'server')
|
||||
const contentReportPath = '/functions/v1/content-report'
|
||||
const dummyGenerationId = '11111111-1111-4111-8111-111111111111'
|
||||
const minimumScenarioCount = 500
|
||||
const fixturePassword = 'D3ro-Red-E2E-2026!'
|
||||
|
||||
function fail(code) {
|
||||
throw new Error(`content_report_red_e2e_failed:${code}`)
|
||||
}
|
||||
|
||||
function assert(condition, code) {
|
||||
if (!condition) fail(code)
|
||||
}
|
||||
|
||||
function readLocalSupabaseEnvironment() {
|
||||
const output = execFileSync('supabase', ['status', '--workdir', supabaseDirectory, '-o', 'env'], {
|
||||
cwd: workspaceRoot,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
})
|
||||
const values = new Map()
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const separator = line.indexOf('=')
|
||||
if (separator <= 0) continue
|
||||
values.set(line.slice(0, separator), line.slice(separator + 1).replace(/^"|"$/g, ''))
|
||||
}
|
||||
const baseUrl = values.get('API_URL')
|
||||
const anonKey = values.get('PUBLISHABLE_KEY') ?? values.get('ANON_KEY')
|
||||
const serviceKey = values.get('SECRET_KEY') ?? values.get('SERVICE_ROLE_KEY')
|
||||
const legacyServiceRoleKey = values.get('SERVICE_ROLE_KEY')
|
||||
assert(baseUrl === 'http://127.0.0.1:55321', 'local_stack_endpoint_required')
|
||||
assert(anonKey?.startsWith('sb_publishable_'), 'local_publishable_key_required')
|
||||
assert(serviceKey?.startsWith('sb_secret_'), 'local_service_key_required')
|
||||
assert(legacyServiceRoleKey?.startsWith('eyJ'), 'local_legacy_service_role_key_required')
|
||||
return { baseUrl, anonKey, serviceKey, legacyServiceRoleKey }
|
||||
}
|
||||
|
||||
const environment = readLocalSupabaseEnvironment()
|
||||
const cleanupUserIds = new Set()
|
||||
const scenarioCounts = new Map()
|
||||
let completedScenarios = 0
|
||||
|
||||
function headersForUser(accessToken, extra = {}) {
|
||||
return {
|
||||
apikey: environment.anonKey,
|
||||
...(accessToken === undefined ? {} : { Authorization: `Bearer ${accessToken}` }),
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function serviceHeaders(extra = {}) {
|
||||
return {
|
||||
apikey: environment.serviceKey,
|
||||
Authorization: `Bearer ${environment.serviceKey}`,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
// Local GoTrue admin-user deletion accepts the legacy service_role JWT. The
|
||||
// suite uses it only for disposable local fixture cleanup; all PostgREST/RPC
|
||||
// service operations use the current sb_secret_ key above.
|
||||
function authAdminHeaders(extra = {}) {
|
||||
return {
|
||||
apikey: environment.legacyServiceRoleKey,
|
||||
Authorization: `Bearer ${environment.legacyServiceRoleKey}`,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, init = {}) {
|
||||
const response = await fetch(`${environment.baseUrl}${path}`, init)
|
||||
const text = await response.text()
|
||||
let json = null
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
cacheControl: response.headers.get('cache-control'),
|
||||
json
|
||||
}
|
||||
}
|
||||
|
||||
function baseReportBody(overrides = {}) {
|
||||
return {
|
||||
kind: 'ai_output',
|
||||
source: { type: 'talk_response', generationId: dummyGenerationId },
|
||||
reason: 'privacy',
|
||||
snapshot: 'Reporter-selected red E2E evidence',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function contentReportRequest({
|
||||
accessToken,
|
||||
method = 'POST',
|
||||
contentType = 'application/json',
|
||||
idempotencyKey = randomUUID(),
|
||||
omitContentType = false,
|
||||
omitIdempotencyKey = false,
|
||||
body = baseReportBody()
|
||||
}) {
|
||||
const headers = headersForUser(accessToken, {
|
||||
...(omitContentType ? {} : { 'Content-Type': contentType }),
|
||||
...(omitIdempotencyKey ? {} : { 'Idempotency-Key': idempotencyKey })
|
||||
})
|
||||
return request(contentReportPath, {
|
||||
method,
|
||||
headers,
|
||||
...(body === undefined || method === 'GET' || method === 'HEAD'
|
||||
? {}
|
||||
: { body: JSON.stringify(body) })
|
||||
})
|
||||
}
|
||||
|
||||
function expectResponse(response, scenario) {
|
||||
assert(response.cacheControl === 'no-store', `${scenario.id}:cache_control`)
|
||||
assert(response.status === scenario.status, `${scenario.id}:status_${response.status}`)
|
||||
if (scenario.code !== undefined) {
|
||||
assert(response.json?.error === scenario.code, `${scenario.id}:code`)
|
||||
}
|
||||
}
|
||||
|
||||
function addScenario(scenarios, family, id, requestFactory, status, code) {
|
||||
scenarios.push({ family, id, requestFactory, status, code })
|
||||
}
|
||||
|
||||
function buildStatelessRedScenarios() {
|
||||
const scenarios = []
|
||||
|
||||
for (const method of ['GET', 'PUT', 'PATCH', 'DELETE']) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'method',
|
||||
`RED-METHOD-${method}`,
|
||||
(context) =>
|
||||
contentReportRequest({ accessToken: context.primary.accessToken, method, body: undefined }),
|
||||
405,
|
||||
'method_not_allowed'
|
||||
)
|
||||
}
|
||||
|
||||
const invalidContentTypes = [
|
||||
{ contentType: 'text/plain' },
|
||||
{ contentType: 'application/jsonp' },
|
||||
{ contentType: 'application/xml' },
|
||||
{ contentType: 'multipart/form-data' },
|
||||
{ contentType: 'application/octet-stream' },
|
||||
{ omitContentType: true }
|
||||
]
|
||||
for (const [index, options] of invalidContentTypes.entries()) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'content_type',
|
||||
`RED-CONTENT-TYPE-${String(index).padStart(2, '0')}`,
|
||||
(context) => contentReportRequest({ accessToken: context.primary.accessToken, ...options }),
|
||||
415,
|
||||
'content_type_must_be_json'
|
||||
)
|
||||
}
|
||||
|
||||
for (const [index, accessToken] of [
|
||||
undefined,
|
||||
environment.anonKey,
|
||||
'not-a-valid-access-token'
|
||||
].entries()) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'authentication',
|
||||
`RED-AUTH-${String(index).padStart(2, '0')}`,
|
||||
() => contentReportRequest({ accessToken }),
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
addScenario(
|
||||
scenarios,
|
||||
'idempotency',
|
||||
'RED-IDEMPOTENCY-MISSING',
|
||||
(context) =>
|
||||
contentReportRequest({ accessToken: context.primary.accessToken, omitIdempotencyKey: true }),
|
||||
400,
|
||||
'idempotency_key_required'
|
||||
)
|
||||
for (let index = 0; index < 95; index += 1) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'idempotency',
|
||||
`RED-IDEMPOTENCY-INVALID-${String(index).padStart(3, '0')}`,
|
||||
(context) =>
|
||||
contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
idempotencyKey: `invalid-idempotency-${index}`
|
||||
}),
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
|
||||
for (const requiredKey of ['kind', 'source', 'reason', 'snapshot']) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'root_shape',
|
||||
`RED-ROOT-MISSING-${requiredKey.toUpperCase()}`,
|
||||
(context) => {
|
||||
const body = baseReportBody()
|
||||
delete body[requiredKey]
|
||||
return contentReportRequest({ accessToken: context.primary.accessToken, body })
|
||||
},
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'root_shape',
|
||||
`RED-ROOT-UNEXPECTED-${String(index).padStart(3, '0')}`,
|
||||
(context) =>
|
||||
contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: { ...baseReportBody(), [`unexpected_${index}`]: { index } }
|
||||
}),
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'kind',
|
||||
`RED-KIND-${String(index).padStart(3, '0')}`,
|
||||
(context) =>
|
||||
contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: baseReportBody({ kind: `non_ai_output_${index}` })
|
||||
}),
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 96; index += 1) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'source',
|
||||
`RED-SOURCE-${String(index).padStart(3, '0')}`,
|
||||
(context) => {
|
||||
let source
|
||||
if (index < 32) {
|
||||
source = { type: `unknown_surface_${index}`, generationId: dummyGenerationId }
|
||||
} else if (index < 64) {
|
||||
source = { type: 'talk_response', generationId: `not-a-uuid-${index}` }
|
||||
} else {
|
||||
const malformedSources = [
|
||||
null,
|
||||
false,
|
||||
index,
|
||||
`source-${index}`,
|
||||
[],
|
||||
{},
|
||||
{ type: 'talk_response' },
|
||||
{ generationId: dummyGenerationId }
|
||||
]
|
||||
source = malformedSources[index % malformedSources.length]
|
||||
}
|
||||
return contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: baseReportBody({ source })
|
||||
})
|
||||
},
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 96; index += 1) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'reason',
|
||||
`RED-REASON-${String(index).padStart(3, '0')}`,
|
||||
(context) =>
|
||||
contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: baseReportBody({ reason: `unsupported_reason_${index}` })
|
||||
}),
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 96; index += 1) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'snapshot',
|
||||
`RED-SNAPSHOT-${String(index).padStart(3, '0')}`,
|
||||
(context) => {
|
||||
let snapshot
|
||||
if (index < 32) snapshot = ' '.repeat(index + 1)
|
||||
else if (index < 64) snapshot = 'x'.repeat(4_001 + index)
|
||||
else {
|
||||
const nonText = [null, false, index, { index }, [index], `\n${' '.repeat(index)}`]
|
||||
snapshot = nonText[index % nonText.length]
|
||||
}
|
||||
return contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: baseReportBody({ snapshot })
|
||||
})
|
||||
},
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
|
||||
for (let index = 0; index < 96; index += 1) {
|
||||
addScenario(
|
||||
scenarios,
|
||||
'comment',
|
||||
`RED-COMMENT-${String(index).padStart(3, '0')}`,
|
||||
(context) => {
|
||||
let comment
|
||||
if (index < 32) comment = ' '.repeat(index + 1)
|
||||
else if (index < 64) comment = 'x'.repeat(501 + index)
|
||||
else {
|
||||
const nonText = [null, false, index, { index }, [index], `\t${' '.repeat(index)}`]
|
||||
comment = nonText[index % nonText.length]
|
||||
}
|
||||
return contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: baseReportBody({ comment })
|
||||
})
|
||||
},
|
||||
400,
|
||||
'invalid_request'
|
||||
)
|
||||
}
|
||||
|
||||
return scenarios
|
||||
}
|
||||
|
||||
async function runScenarioBatch(scenarios, context) {
|
||||
for (let offset = 0; offset < scenarios.length; offset += 12) {
|
||||
await Promise.all(
|
||||
scenarios.slice(offset, offset + 12).map(async (scenario) => {
|
||||
const response = await scenario.requestFactory(context)
|
||||
expectResponse(response, scenario)
|
||||
completedScenarios += 1
|
||||
scenarioCounts.set(scenario.family, (scenarioCounts.get(scenario.family) ?? 0) + 1)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function createFixtureUser(label) {
|
||||
const email = `content-report-red-${label}-${randomUUID().replaceAll('-', '')}@example.invalid`
|
||||
const response = await request('/auth/v1/signup', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(environment.anonKey, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ email, password: fixturePassword })
|
||||
})
|
||||
assert(response.status === 200, `signup_${label}_status_${response.status}`)
|
||||
assert(typeof response.json?.user?.id === 'string', `signup_${label}_user_id`)
|
||||
assert(typeof response.json?.access_token === 'string', `signup_${label}_access_token`)
|
||||
cleanupUserIds.add(response.json.user.id)
|
||||
return { id: response.json.user.id, accessToken: response.json.access_token }
|
||||
}
|
||||
|
||||
async function issueReceipt(userId, purpose) {
|
||||
const response = await request('/rest/v1/rpc/issue_content_generation_receipt_v1', {
|
||||
method: 'POST',
|
||||
headers: serviceHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
p_actor_id: userId,
|
||||
p_purpose: purpose,
|
||||
p_model: 'content-report-red-e2e'
|
||||
})
|
||||
})
|
||||
assert(response.status === 200, `receipt_${purpose}_status_${response.status}`)
|
||||
assert(typeof response.json?.generationId === 'string', `receipt_${purpose}_generation_id`)
|
||||
return response.json.generationId
|
||||
}
|
||||
|
||||
async function submitValidReport(
|
||||
user,
|
||||
sourceType,
|
||||
generationId,
|
||||
reason,
|
||||
suffix,
|
||||
idempotencyKey = randomUUID()
|
||||
) {
|
||||
const payload = {
|
||||
kind: 'ai_output',
|
||||
source: { type: sourceType, generationId },
|
||||
reason,
|
||||
comment: `Red E2E ${suffix}`,
|
||||
snapshot: `Reporter-selected Red E2E evidence ${suffix}`
|
||||
}
|
||||
const response = await contentReportRequest({
|
||||
accessToken: user.accessToken,
|
||||
idempotencyKey,
|
||||
body: payload
|
||||
})
|
||||
return { response, payload, idempotencyKey }
|
||||
}
|
||||
|
||||
async function runStatefulScenario(id, family, action) {
|
||||
await action()
|
||||
completedScenarios += 1
|
||||
scenarioCounts.set(family, (scenarioCounts.get(family) ?? 0) + 1)
|
||||
}
|
||||
|
||||
async function configureManager(user) {
|
||||
const profile = await request(`/rest/v1/profiles?id=eq.${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: serviceHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation'
|
||||
}),
|
||||
body: JSON.stringify({ role: 'manager' })
|
||||
})
|
||||
assert(profile.status === 200, `manager_profile_status_${profile.status}`)
|
||||
}
|
||||
|
||||
async function runStatefulRedScenarios(context) {
|
||||
const sources = []
|
||||
for (const [index, sourceType] of [
|
||||
'talk_response',
|
||||
'command_response',
|
||||
'action_response'
|
||||
].entries()) {
|
||||
const generationId = await issueReceipt(context.primary.id, sourceType)
|
||||
const scenario = await submitValidReport(
|
||||
context.primary,
|
||||
sourceType,
|
||||
generationId,
|
||||
['harmful', 'privacy', 'spam'][index],
|
||||
`source-${sourceType}`
|
||||
)
|
||||
expectResponse(scenario.response, { id: `STATE-VALID-${sourceType}`, status: 201 })
|
||||
assert(scenario.response.json?.idempotent === false, `STATE-VALID-${sourceType}:not_idempotent`)
|
||||
sources.push({ sourceType, generationId, ...scenario })
|
||||
await runStatefulScenario(`STATE-VALID-${sourceType}`, 'valid_source', async () => {})
|
||||
}
|
||||
|
||||
const first = sources[0]
|
||||
await runStatefulScenario('STATE-IDEMPOTENT-REPLAY', 'idempotency_state', async () => {
|
||||
const replay = await contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
idempotencyKey: first.idempotencyKey,
|
||||
body: first.payload
|
||||
})
|
||||
expectResponse(replay, { id: 'STATE-IDEMPOTENT-REPLAY', status: 200 })
|
||||
assert(replay.json?.idempotent === true, 'STATE-IDEMPOTENT-REPLAY:not_idempotent')
|
||||
})
|
||||
await runStatefulScenario('STATE-IDEMPOTENCY-CONFLICT', 'idempotency_state', async () => {
|
||||
const conflict = await contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
idempotencyKey: first.idempotencyKey,
|
||||
body: { ...first.payload, snapshot: `${first.payload.snapshot} changed` }
|
||||
})
|
||||
expectResponse(conflict, {
|
||||
id: 'STATE-IDEMPOTENCY-CONFLICT',
|
||||
status: 409,
|
||||
code: 'idempotency_conflict'
|
||||
})
|
||||
})
|
||||
await runStatefulScenario('STATE-DUPLICATE-SOURCE', 'source_ownership', async () => {
|
||||
const duplicate = await contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: first.payload
|
||||
})
|
||||
expectResponse(duplicate, {
|
||||
id: 'STATE-DUPLICATE-SOURCE',
|
||||
status: 409,
|
||||
code: 'source_already_reported'
|
||||
})
|
||||
})
|
||||
await runStatefulScenario('STATE-CROSS-USER-SOURCE', 'source_ownership', async () => {
|
||||
const crossUser = await contentReportRequest({
|
||||
accessToken: context.secondary.accessToken,
|
||||
body: { ...first.payload, snapshot: 'Cross-user source attempt' }
|
||||
})
|
||||
expectResponse(crossUser, {
|
||||
id: 'STATE-CROSS-USER-SOURCE',
|
||||
status: 404,
|
||||
code: 'source_not_found'
|
||||
})
|
||||
})
|
||||
await runStatefulScenario('STATE-SOURCE-PURPOSE-MISMATCH', 'source_ownership', async () => {
|
||||
const mismatch = await contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: {
|
||||
...first.payload,
|
||||
source: { type: 'action_response', generationId: first.generationId },
|
||||
snapshot: 'Purpose mismatch attempt'
|
||||
}
|
||||
})
|
||||
expectResponse(mismatch, {
|
||||
id: 'STATE-SOURCE-PURPOSE-MISMATCH',
|
||||
status: 404,
|
||||
code: 'source_not_found'
|
||||
})
|
||||
})
|
||||
await runStatefulScenario('STATE-UNKNOWN-SOURCE', 'source_ownership', async () => {
|
||||
const unknownSource = await contentReportRequest({
|
||||
accessToken: context.primary.accessToken,
|
||||
body: {
|
||||
...first.payload,
|
||||
source: { type: 'talk_response', generationId: randomUUID() },
|
||||
snapshot: 'Unknown source attempt'
|
||||
}
|
||||
})
|
||||
expectResponse(unknownSource, {
|
||||
id: 'STATE-UNKNOWN-SOURCE',
|
||||
status: 404,
|
||||
code: 'source_not_found'
|
||||
})
|
||||
})
|
||||
await runStatefulScenario('STATE-DIRECT-LEDGER-READ', 'ledger_acl', async () => {
|
||||
const directRead = await request('/rest/v1/content_reports?select=id', {
|
||||
headers: headersForUser(context.primary.accessToken)
|
||||
})
|
||||
assert(directRead.status === 403, `STATE-DIRECT-LEDGER-READ:status_${directRead.status}`)
|
||||
})
|
||||
|
||||
for (let index = 0; index < 11; index += 1) {
|
||||
await runStatefulScenario(
|
||||
`STATE-RATE-${String(index + 1).padStart(2, '0')}`,
|
||||
'rate_limit',
|
||||
async () => {
|
||||
const generationId = await issueReceipt(context.rateLimited.id, 'talk_response')
|
||||
const submission = await submitValidReport(
|
||||
context.rateLimited,
|
||||
'talk_response',
|
||||
generationId,
|
||||
'other',
|
||||
`rate-${index}`
|
||||
)
|
||||
expectResponse(submission.response, {
|
||||
id: `STATE-RATE-${String(index + 1).padStart(2, '0')}`,
|
||||
status: index < 10 ? 201 : 429,
|
||||
...(index < 10 ? {} : { code: 'report_rate_limited' })
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
await configureManager(context.manager)
|
||||
let reportId
|
||||
await runStatefulScenario('STATE-ORDINARY-MODERATION-DENIED', 'moderation_acl', async () => {
|
||||
const ordinary = await request('/rest/v1/rpc/admin_list_content_reports_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.primary.accessToken, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ p_status: 'pending', p_limit: 100, p_before: null })
|
||||
})
|
||||
assert(ordinary.status === 403, `STATE-ORDINARY-MODERATION-DENIED:status_${ordinary.status}`)
|
||||
})
|
||||
await runStatefulScenario('STATE-MANAGER-QUEUE-READ', 'moderation_acl', async () => {
|
||||
const queue = await request('/rest/v1/rpc/admin_list_content_reports_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.manager.accessToken, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ p_status: 'pending', p_limit: 100, p_before: null })
|
||||
})
|
||||
assert(queue.status === 200, `STATE-MANAGER-QUEUE-READ:status_${queue.status}`)
|
||||
reportId = queue.json?.find((report) => report.source_id === first.generationId)?.report_id
|
||||
assert(typeof reportId === 'string', 'STATE-MANAGER-QUEUE-READ:report_id')
|
||||
})
|
||||
|
||||
const reviewKey = randomUUID()
|
||||
await runStatefulScenario('STATE-MANAGER-BEGIN-REVIEW', 'moderation_transition', async () => {
|
||||
const review = await request('/rest/v1/rpc/admin_act_on_content_report_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.manager.accessToken, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
p_report_id: reportId,
|
||||
p_idempotency_key: reviewKey,
|
||||
p_action: 'begin_review',
|
||||
p_note: 'Red E2E review started'
|
||||
})
|
||||
})
|
||||
assert(
|
||||
review.status === 200 && review.json?.status === 'reviewing',
|
||||
'STATE-MANAGER-BEGIN-REVIEW:result'
|
||||
)
|
||||
})
|
||||
await runStatefulScenario('STATE-MANAGER-REVIEW-REPLAY', 'moderation_transition', async () => {
|
||||
const replay = await request('/rest/v1/rpc/admin_act_on_content_report_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.manager.accessToken, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
p_report_id: reportId,
|
||||
p_idempotency_key: reviewKey,
|
||||
p_action: 'begin_review',
|
||||
p_note: 'Red E2E review started'
|
||||
})
|
||||
})
|
||||
assert(
|
||||
replay.status === 200 && replay.json?.idempotent === true,
|
||||
'STATE-MANAGER-REVIEW-REPLAY:result'
|
||||
)
|
||||
})
|
||||
await runStatefulScenario('STATE-MANAGER-REVIEW-CONFLICT', 'moderation_transition', async () => {
|
||||
const conflict = await request('/rest/v1/rpc/admin_act_on_content_report_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.manager.accessToken, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
p_report_id: reportId,
|
||||
p_idempotency_key: reviewKey,
|
||||
p_action: 'begin_review',
|
||||
p_note: 'Changed review note'
|
||||
})
|
||||
})
|
||||
assert(conflict.status === 409, `STATE-MANAGER-REVIEW-CONFLICT:status_${conflict.status}`)
|
||||
})
|
||||
await runStatefulScenario('STATE-MANAGER-DISMISS', 'moderation_transition', async () => {
|
||||
const dismiss = await request('/rest/v1/rpc/admin_act_on_content_report_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.manager.accessToken, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
p_report_id: reportId,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_action: 'dismiss',
|
||||
p_note: 'No policy violation in selected evidence'
|
||||
})
|
||||
})
|
||||
assert(
|
||||
dismiss.status === 200 && dismiss.json?.status === 'dismissed',
|
||||
'STATE-MANAGER-DISMISS:result'
|
||||
)
|
||||
})
|
||||
await runStatefulScenario('STATE-RESOLVED-REPORT-LOCKED', 'moderation_transition', async () => {
|
||||
const resolved = await request('/rest/v1/rpc/admin_act_on_content_report_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.manager.accessToken, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
p_report_id: reportId,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_action: 'dismiss',
|
||||
p_note: 'Repeat terminal transition denied'
|
||||
})
|
||||
})
|
||||
assert(resolved.status === 409, `STATE-RESOLVED-REPORT-LOCKED:status_${resolved.status}`)
|
||||
})
|
||||
await runStatefulScenario(
|
||||
'STATE-ORDINARY-MODERATION-ACTION-DENIED',
|
||||
'moderation_acl',
|
||||
async () => {
|
||||
const ordinary = await request('/rest/v1/rpc/admin_act_on_content_report_v1', {
|
||||
method: 'POST',
|
||||
headers: headersForUser(context.primary.accessToken, {
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
p_report_id: reportId,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_action: 'begin_review',
|
||||
p_note: 'Ordinary user must not review reports'
|
||||
})
|
||||
})
|
||||
assert(
|
||||
ordinary.status === 403,
|
||||
`STATE-ORDINARY-MODERATION-ACTION-DENIED:status_${ordinary.status}`
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
const failures = []
|
||||
for (const userId of cleanupUserIds) {
|
||||
const reports = await request(`/rest/v1/content_reports?reporter_id=eq.${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: serviceHeaders()
|
||||
})
|
||||
if (![200, 204].includes(reports.status)) failures.push(`report_delete_${reports.status}`)
|
||||
}
|
||||
for (const userId of cleanupUserIds) {
|
||||
const user = await request(`/auth/v1/admin/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authAdminHeaders()
|
||||
})
|
||||
if (![200, 204].includes(user.status)) failures.push(`user_delete_${user.status}`)
|
||||
}
|
||||
assert(failures.length === 0, `fixture_cleanup_${failures.join('_')}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const context = {
|
||||
primary: await createFixtureUser('primary'),
|
||||
secondary: await createFixtureUser('secondary'),
|
||||
rateLimited: await createFixtureUser('rate'),
|
||||
manager: await createFixtureUser('manager')
|
||||
}
|
||||
const statelessScenarios = buildStatelessRedScenarios()
|
||||
assert(statelessScenarios.length === 621, `stateless_scenario_count_${statelessScenarios.length}`)
|
||||
await runScenarioBatch(statelessScenarios, context)
|
||||
await runStatefulRedScenarios(context)
|
||||
assert(completedScenarios >= minimumScenarioCount, `minimum_scenario_count_${completedScenarios}`)
|
||||
assert(completedScenarios === 650, `exact_scenario_count_${completedScenarios}`)
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
suite: 'content-report-red-e2e',
|
||||
localOnly: true,
|
||||
scenarios: completedScenarios,
|
||||
families: Object.fromEntries(
|
||||
[...scenarioCounts.entries()].sort(([left], [right]) => left.localeCompare(right))
|
||||
),
|
||||
cleanupUsers: cleanupUserIds.size
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
await cleanup()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue