613 lines
24 KiB
JavaScript
613 lines
24 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
const supabaseUrl = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''
|
|
const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? ''
|
|
const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
|
|
|
if (!supabaseUrl.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20) {
|
|
throw new Error('D3RO local Supabase credentials for port 55321 are required')
|
|
}
|
|
|
|
const options = {
|
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
}
|
|
const admin = createClient(supabaseUrl, serviceRoleKey, options)
|
|
const anonymousClient = createClient(supabaseUrl, anonKey, options)
|
|
const userAClient = createClient(supabaseUrl, anonKey, options)
|
|
const userBClient = createClient(supabaseUrl, anonKey, options)
|
|
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`
|
|
const password = `D3ro-${suffix}-Strong!`
|
|
const createdUserIds = []
|
|
let assertions = 0
|
|
|
|
function checked(condition, message) {
|
|
assert.ok(condition, message)
|
|
assertions += 1
|
|
}
|
|
|
|
async function createDisposableUser(client, marker) {
|
|
const response = await client.auth.signUp({
|
|
email: `d3ro.templates.${suffix}.${marker}@gmail.com`,
|
|
password,
|
|
})
|
|
assert.equal(response.error, null, `Could not create local user ${marker}`)
|
|
assert.ok(response.data.user && response.data.session, `Local user ${marker} has no session`)
|
|
createdUserIds.push(response.data.user.id)
|
|
return response.data
|
|
}
|
|
|
|
async function countRows(table, filters) {
|
|
let query = admin.from(table).select('*', { count: 'exact', head: true })
|
|
for (const [column, value] of Object.entries(filters)) query = query.eq(column, value)
|
|
const result = await query
|
|
assert.equal(result.error, null, `Could not count ${table}`)
|
|
return result.count ?? 0
|
|
}
|
|
|
|
try {
|
|
const [userA, userB] = await Promise.all([
|
|
createDisposableUser(userAClient, 'a'),
|
|
createDisposableUser(userBClient, 'b'),
|
|
])
|
|
const userAId = userA.user.id
|
|
const userBId = userB.user.id
|
|
|
|
// Local GoTrue and PostgREST can straddle adjacent whole-second JWT clocks.
|
|
// Let the freshly issued `iat` become current before the first REST RPC.
|
|
await new Promise((resolve) => setTimeout(resolve, 1_100))
|
|
|
|
const anonymousGeneration = await fetch(`${supabaseUrl}/functions/v1/generate-meeting-document`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
checked(anonymousGeneration.status === 401, 'Document generation accepted an anonymous request')
|
|
|
|
const invalidGeneration = await fetch(`${supabaseUrl}/functions/v1/generate-meeting-document`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${userA.session.access_token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
meetingId: crypto.randomUUID(),
|
|
templateId: crypto.randomUUID(),
|
|
idempotencyKey: crypto.randomUUID(),
|
|
title: 'Invalid override',
|
|
prompt: 'Client prompts must never override the server template',
|
|
}),
|
|
})
|
|
const invalidGenerationBody = await invalidGeneration.json().catch(() => null)
|
|
checked(
|
|
invalidGeneration.status === 400 && invalidGenerationBody?.error === 'invalid_request',
|
|
'Document generation accepted unsupported client-controlled prompt fields',
|
|
)
|
|
|
|
const bootstrapRuns = await Promise.all(
|
|
Array.from({ length: 8 }, () => userAClient.rpc('bootstrap_user_templates_v1')),
|
|
)
|
|
for (const run of bootstrapRuns) assert.equal(run.error, null, 'Concurrent template bootstrap failed')
|
|
const templatesA = await userAClient
|
|
.from('user_templates')
|
|
.select('*')
|
|
.eq('user_id', userAId)
|
|
.order('builtin_key')
|
|
assert.equal(templatesA.error, null)
|
|
checked(templatesA.data.length === 7, 'Concurrent bootstrap created duplicate builtins')
|
|
checked(
|
|
new Set(templatesA.data.map((row) => `${row.template_kind}:${row.builtin_key}`)).size === 7,
|
|
'Builtin kind/key identities are not unique',
|
|
)
|
|
checked(
|
|
templatesA.data.find((row) => row.builtin_key === 'builtin-email')?.output_format
|
|
=== 'To: {{recipient}}\nSubject: {{subject}}\n\n{{body}}',
|
|
'Desktop email builtin semantics changed',
|
|
)
|
|
checked(
|
|
templatesA.data.find((row) => row.builtin_key === 'builtin-minutes')?.system_prompt
|
|
?.includes('전사록에 없는 내용을 추가하지 마세요') === true,
|
|
'Desktop minutes builtin prompt semantics changed',
|
|
)
|
|
checked(
|
|
await countRows('user_template_selections', { user_id: userAId }) === 2,
|
|
'Concurrent bootstrap did not create exactly two default selections',
|
|
)
|
|
|
|
const builtinEmail = templatesA.data.find((row) => row.builtin_key === 'builtin-email')
|
|
assert.ok(builtinEmail)
|
|
const builtinMutation = await userAClient.rpc('update_user_template_v1', {
|
|
p_template_id: builtinEmail.id,
|
|
p_expected_revision: builtinEmail.revision,
|
|
p_name: 'Tampered',
|
|
p_description: null,
|
|
p_fields: builtinEmail.fields,
|
|
p_output_format: builtinEmail.output_format,
|
|
p_system_prompt: null,
|
|
})
|
|
checked(builtinMutation.error?.code === '42501', 'Builtin template mutation was not rejected')
|
|
|
|
const customCreate = await userAClient.rpc('create_user_template_v1', {
|
|
p_template_kind: 'dictation',
|
|
p_name: 'Concurrent custom',
|
|
p_description: 'CAS verification',
|
|
p_fields: [{
|
|
id: 'content', name: 'content', label: 'Content',
|
|
promptText: 'Dictate content', required: true, maxDurationSec: 120,
|
|
}],
|
|
p_output_format: '{{content}}',
|
|
p_system_prompt: null,
|
|
})
|
|
assert.equal(customCreate.error, null, 'Could not create custom template')
|
|
const custom = customCreate.data
|
|
|
|
const strangerTemplateRead = await userBClient
|
|
.from('user_templates')
|
|
.select('id')
|
|
.eq('id', custom.id)
|
|
assert.equal(strangerTemplateRead.error, null)
|
|
checked(strangerTemplateRead.data.length === 0, 'Another account can read a private template')
|
|
|
|
const updateArgs = {
|
|
p_template_id: custom.id,
|
|
p_expected_revision: custom.revision,
|
|
p_description: 'Concurrent update',
|
|
p_fields: custom.fields,
|
|
p_output_format: custom.output_format,
|
|
p_system_prompt: null,
|
|
}
|
|
const concurrentUpdateStartedAt = Date.now()
|
|
const concurrentUpdates = await Promise.all([
|
|
userAClient.rpc('update_user_template_v1', { ...updateArgs, p_name: 'Winner A' }),
|
|
userAClient.rpc('update_user_template_v1', { ...updateArgs, p_name: 'Winner B' }),
|
|
])
|
|
const concurrentUpdateEvidence = concurrentUpdates.map((result) => ({
|
|
ok: result.error === null,
|
|
code: result.error?.code ?? null,
|
|
message: result.error?.message ?? null,
|
|
}))
|
|
checked(
|
|
concurrentUpdates.filter((result) => result.error === null).length === 1
|
|
&& concurrentUpdates.filter((result) => result.error?.code === 'PT409').length === 1,
|
|
`Template revision CAS did not admit exactly one concurrent update: ${JSON.stringify(concurrentUpdateEvidence)}`,
|
|
)
|
|
checked(
|
|
Date.now() - concurrentUpdateStartedAt < 5_000,
|
|
'Template revision conflict was retried or delayed instead of returning immediately',
|
|
)
|
|
|
|
const historyInsert = await userAClient
|
|
.from('history')
|
|
.insert({
|
|
user_id: userAId,
|
|
title: 'Private launch memo',
|
|
original_text: 'Ship only after rollback validation',
|
|
duration: 1,
|
|
mode: 'dictation',
|
|
status: 'completed',
|
|
})
|
|
.select('id')
|
|
.single()
|
|
assert.equal(historyInsert.error, null, 'Could not create memo history row')
|
|
const historyId = historyInsert.data.id
|
|
|
|
const concurrentTags = await Promise.all([
|
|
userAClient.rpc('mobile_add_memo_tag_v1', { p_history_id: historyId, p_tag: 'Launch Plan' }),
|
|
userAClient.rpc('mobile_add_memo_tag_v1', { p_history_id: historyId, p_tag: 'launch plan' }),
|
|
])
|
|
for (const result of concurrentTags) assert.equal(result.error, null, 'Concurrent tag insert failed')
|
|
checked(
|
|
concurrentTags[0].data.id === concurrentTags[1].data.id,
|
|
'Case-insensitive concurrent tag insert was not idempotent',
|
|
)
|
|
checked(
|
|
await countRows('memo_tags', { history_id: historyId }) === 1,
|
|
'Case-insensitive duplicate memo tags were stored',
|
|
)
|
|
|
|
const strangerTag = await userBClient.rpc('mobile_add_memo_tag_v1', {
|
|
p_history_id: historyId,
|
|
p_tag: 'stolen',
|
|
})
|
|
checked(strangerTag.error?.code === 'P0002', 'Another account attached a tag to private history')
|
|
const forgedDirectTag = await userBClient.from('memo_tags').insert({
|
|
user_id: userBId,
|
|
history_id: historyId,
|
|
tag: 'forged',
|
|
normalized_tag: 'forged',
|
|
})
|
|
checked(forgedDirectTag.error !== null, 'Authenticated clients retained direct memo-tag write access')
|
|
|
|
const ownerMemoSearch = await userAClient.rpc('mobile_search_memos_v1', {
|
|
p_query: 'launch', p_tag: 'LAUNCH PLAN', p_limit: 20, p_offset: 0,
|
|
})
|
|
assert.equal(ownerMemoSearch.error, null)
|
|
checked(ownerMemoSearch.data.length === 1, 'Owner tag/content search did not return the memo')
|
|
const strangerMemoSearch = await userBClient.rpc('mobile_search_memos_v1', {
|
|
p_query: 'launch', p_tag: null, p_limit: 20, p_offset: 0,
|
|
})
|
|
assert.equal(strangerMemoSearch.error, null)
|
|
checked(strangerMemoSearch.data.length === 0, 'Another account can search private memos')
|
|
|
|
const caseRename = await userAClient.rpc('mobile_rename_memo_tag_v1', {
|
|
p_old_tag: 'launch plan', p_new_tag: 'LAUNCH PLAN',
|
|
})
|
|
assert.equal(caseRename.error, null)
|
|
checked(caseRename.data === 1, 'Case-only rename did not update exactly one memo')
|
|
checked(
|
|
await countRows('memo_tags', { history_id: historyId }) === 1,
|
|
'Case-only rename removed or duplicated the tag',
|
|
)
|
|
|
|
const bootstrapB = await userBClient.rpc('bootstrap_user_templates_v1')
|
|
assert.equal(bootstrapB.error, null)
|
|
const minutesA = templatesA.data.find((row) => row.builtin_key === 'builtin-minutes')
|
|
const minutesB = bootstrapB.data.find((row) => row.builtin_key === 'builtin-minutes')
|
|
assert.ok(minutesA && minutesB)
|
|
|
|
const anonymousCreation = await anonymousClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
p_title: 'Anonymous attempt',
|
|
p_attendees: [],
|
|
p_language: 'ko',
|
|
p_template_id: minutesA.id,
|
|
p_idempotency_key: crypto.randomUUID(),
|
|
})
|
|
checked(anonymousCreation.error?.code === '42501', 'Anonymous meeting creation was accepted')
|
|
|
|
const creationKey = crypto.randomUUID()
|
|
const creationArgs = {
|
|
p_title: ' H-006 launch review ',
|
|
p_attendees: ['Yun Chan', 'Product Team'],
|
|
p_language: 'EN',
|
|
p_template_id: minutesA.id,
|
|
p_idempotency_key: creationKey,
|
|
}
|
|
const concurrentCreations = await Promise.all(
|
|
Array.from({ length: 8 }, () => (
|
|
userAClient.rpc('mobile_create_meeting_workspace_v2', creationArgs)
|
|
)),
|
|
)
|
|
for (const result of concurrentCreations) {
|
|
assert.equal(result.error, null, 'Concurrent meeting creation failed')
|
|
}
|
|
const createdMeeting = concurrentCreations[0].data
|
|
checked(
|
|
concurrentCreations.every((result) => result.data.id === createdMeeting.id)
|
|
&& await countRows('meetings', {
|
|
user_id: userAId,
|
|
creation_idempotency_key: creationKey,
|
|
}) === 1,
|
|
'Concurrent meeting creation was not idempotent',
|
|
)
|
|
checked(
|
|
createdMeeting.user_id === userAId
|
|
&& createdMeeting.title === 'H-006 launch review'
|
|
&& createdMeeting.status === 'recording'
|
|
&& createdMeeting.language === 'en'
|
|
&& JSON.stringify(createdMeeting.attendees) === JSON.stringify(['Yun Chan', 'Product Team'])
|
|
&& createdMeeting.template_id === minutesA.id
|
|
&& createdMeeting.creation_idempotency_key === creationKey,
|
|
'Atomic meeting creation did not return the normalized server row',
|
|
)
|
|
|
|
const forgedCreationMetadata = await userAClient
|
|
.from('meetings')
|
|
.insert({
|
|
user_id: userAId,
|
|
title: 'Direct metadata bypass',
|
|
status: 'recording',
|
|
language: 'ja',
|
|
attendees: ['Bypass'],
|
|
template_id: minutesA.id,
|
|
})
|
|
checked(
|
|
forgedCreationMetadata.error?.code === '42501',
|
|
'Direct INSERT bypassed the atomic H-006 meeting creation RPC',
|
|
)
|
|
|
|
const forgedIdentityUpdate = await userAClient
|
|
.from('meetings')
|
|
.update({ creation_idempotency_key: crypto.randomUUID() })
|
|
.eq('id', createdMeeting.id)
|
|
checked(
|
|
forgedIdentityUpdate.error?.code === '42501',
|
|
'Direct UPDATE mutated an atomic meeting creation identity',
|
|
)
|
|
const preservedCreationIdentity = await userAClient
|
|
.from('meetings')
|
|
.select('creation_idempotency_key, creation_request_hash')
|
|
.eq('id', createdMeeting.id)
|
|
.single()
|
|
assert.equal(preservedCreationIdentity.error, null)
|
|
checked(
|
|
preservedCreationIdentity.data.creation_idempotency_key === creationKey
|
|
&& /^[0-9a-f]{64}$/.test(preservedCreationIdentity.data.creation_request_hash),
|
|
'Rejected identity mutation changed the committed creation metadata',
|
|
)
|
|
|
|
const strangerMeetingRead = await userBClient
|
|
.from('meetings')
|
|
.select('id')
|
|
.eq('id', createdMeeting.id)
|
|
assert.equal(strangerMeetingRead.error, null)
|
|
checked(strangerMeetingRead.data.length === 0, 'Another account can read a private created meeting')
|
|
|
|
const creationConflictStartedAt = Date.now()
|
|
const creationConflict = await userAClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
...creationArgs,
|
|
p_title: 'Different request with the same key',
|
|
})
|
|
checked(
|
|
creationConflict.error?.code === 'PT409'
|
|
&& Date.now() - creationConflictStartedAt < 5_000,
|
|
`Meeting creation conflict did not return immediate HTTP 409 semantics: ${JSON.stringify(creationConflict.error)}`,
|
|
)
|
|
const unchangedCreation = await userAClient
|
|
.from('meetings')
|
|
.select('title, attendees, language, template_id')
|
|
.eq('id', createdMeeting.id)
|
|
.single()
|
|
assert.equal(unchangedCreation.error, null)
|
|
checked(
|
|
unchangedCreation.data.title === 'H-006 launch review'
|
|
&& unchangedCreation.data.language === 'en'
|
|
&& unchangedCreation.data.template_id === minutesA.id,
|
|
'Idempotency conflict mutated the already committed meeting',
|
|
)
|
|
|
|
const foreignTemplateCreationKey = crypto.randomUUID()
|
|
const foreignTemplateCreation = await userBClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
p_title: 'Foreign template attempt',
|
|
p_attendees: [],
|
|
p_language: 'ko',
|
|
p_template_id: minutesA.id,
|
|
p_idempotency_key: foreignTemplateCreationKey,
|
|
})
|
|
checked(
|
|
foreignTemplateCreation.error?.code === 'P0002'
|
|
&& await countRows('meetings', {
|
|
user_id: userBId,
|
|
creation_idempotency_key: foreignTemplateCreationKey,
|
|
}) === 0,
|
|
'Another account created a meeting with a private template or left a partial row',
|
|
)
|
|
|
|
const invalidAttendeesCreationKey = crypto.randomUUID()
|
|
const invalidAttendeesCreation = await userAClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
p_title: 'Invalid attendees rollback',
|
|
p_attendees: ['Yun Chan', 'yun chan'],
|
|
p_language: 'ko',
|
|
p_template_id: minutesA.id,
|
|
p_idempotency_key: invalidAttendeesCreationKey,
|
|
})
|
|
checked(
|
|
invalidAttendeesCreation.error?.code === '22023'
|
|
&& await countRows('meetings', {
|
|
user_id: userAId,
|
|
creation_idempotency_key: invalidAttendeesCreationKey,
|
|
}) === 0,
|
|
'Invalid meeting creation left a partial row instead of rolling back',
|
|
)
|
|
|
|
const nullTitleCreationKey = crypto.randomUUID()
|
|
const nullTitleCreation = await userAClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
p_title: null,
|
|
p_attendees: [],
|
|
p_language: 'ko',
|
|
p_template_id: minutesA.id,
|
|
p_idempotency_key: nullTitleCreationKey,
|
|
})
|
|
checked(
|
|
nullTitleCreation.error?.code === '22023'
|
|
&& await countRows('meetings', {
|
|
user_id: userAId,
|
|
creation_idempotency_key: nullTitleCreationKey,
|
|
}) === 0,
|
|
'NULL meeting title bypassed validation or left a partial row',
|
|
)
|
|
|
|
const competingCreationKey = crypto.randomUUID()
|
|
const competingCreations = await Promise.all([
|
|
userAClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
...creationArgs,
|
|
p_title: 'Competing payload A',
|
|
p_idempotency_key: competingCreationKey,
|
|
}),
|
|
userAClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
...creationArgs,
|
|
p_title: 'Competing payload B',
|
|
p_idempotency_key: competingCreationKey,
|
|
}),
|
|
])
|
|
checked(
|
|
competingCreations.filter((result) => result.error === null).length === 1
|
|
&& competingCreations.filter((result) => result.error?.code === 'PT409').length === 1
|
|
&& await countRows('meetings', {
|
|
user_id: userAId,
|
|
creation_idempotency_key: competingCreationKey,
|
|
}) === 1,
|
|
`Different concurrent meeting payloads did not admit exactly one winner: ${JSON.stringify(competingCreations.map((result) => ({ error: result.error?.code ?? null, id: result.data?.id ?? null })))}`,
|
|
)
|
|
|
|
const referencedTemplateCreate = await userAClient.rpc('create_user_template_v1', {
|
|
p_template_kind: 'meeting_document',
|
|
p_name: 'Referenced document template',
|
|
p_description: 'Deletion must fail while a meeting references this revision',
|
|
p_fields: [],
|
|
p_output_format: null,
|
|
p_system_prompt: 'Use only the supplied transcript.',
|
|
})
|
|
assert.equal(referencedTemplateCreate.error, null, 'Could not create referenced document template')
|
|
const referencedTemplate = referencedTemplateCreate.data
|
|
const referencedMeeting = await userAClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
p_title: 'Template retention proof',
|
|
p_attendees: [],
|
|
p_language: 'zh-cn',
|
|
p_template_id: referencedTemplate.id,
|
|
p_idempotency_key: crypto.randomUUID(),
|
|
})
|
|
assert.equal(referencedMeeting.error, null, 'Could not create custom-template meeting')
|
|
const referencedTemplateDelete = await userAClient.rpc('delete_user_template_v1', {
|
|
p_template_id: referencedTemplate.id,
|
|
p_expected_revision: referencedTemplate.revision,
|
|
})
|
|
checked(
|
|
referencedTemplateDelete.error?.code === '23503'
|
|
&& await countRows('user_templates', { id: referencedTemplate.id }) === 1
|
|
&& await countRows('meetings', {
|
|
id: referencedMeeting.data.id,
|
|
template_id: referencedTemplate.id,
|
|
}) === 1,
|
|
'Referenced meeting template was deleted or its selection link was lost',
|
|
)
|
|
|
|
const userBCreationKey = crypto.randomUUID()
|
|
const userBCreation = await userBClient.rpc('mobile_create_meeting_workspace_v2', {
|
|
p_title: 'Owner template trigger proof',
|
|
p_attendees: [],
|
|
p_language: 'ko',
|
|
p_template_id: minutesB.id,
|
|
p_idempotency_key: userBCreationKey,
|
|
})
|
|
assert.equal(userBCreation.error, null, 'Second account could not create its own meeting')
|
|
const forgedTemplateUpdate = await userBClient
|
|
.from('meetings')
|
|
.update({ template_id: minutesA.id })
|
|
.eq('id', userBCreation.data.id)
|
|
checked(forgedTemplateUpdate.error?.code === '42501', 'Direct update bypassed template ownership')
|
|
const preservedUserBMeeting = await userBClient
|
|
.from('meetings')
|
|
.select('template_id')
|
|
.eq('id', userBCreation.data.id)
|
|
.single()
|
|
assert.equal(preservedUserBMeeting.error, null)
|
|
checked(
|
|
preservedUserBMeeting.data.template_id === minutesB.id,
|
|
'Rejected cross-owner template update changed the meeting row',
|
|
)
|
|
|
|
const meetingInsert = await userAClient
|
|
.from('meetings')
|
|
.insert({
|
|
user_id: userAId,
|
|
title: 'Atomic generation meeting',
|
|
status: 'completed',
|
|
raw_transcript: 'Yun: Ship after every rollback check passes.',
|
|
})
|
|
.select('id')
|
|
.single()
|
|
assert.equal(meetingInsert.error, null, 'Could not create generation meeting')
|
|
const meetingId = meetingInsert.data.id
|
|
|
|
const strangerClaim = await admin.rpc('claim_meeting_document_generation_v1', {
|
|
p_actor_id: userBId,
|
|
p_idempotency_key: crypto.randomUUID(),
|
|
p_meeting_id: meetingId,
|
|
p_template_id: minutesB.id,
|
|
p_title: 'Forbidden document',
|
|
p_model: 'claude-haiku-4-5-20251001',
|
|
})
|
|
checked(strangerClaim.error?.code === '42501', 'Another account claimed a private meeting generation')
|
|
|
|
const successKey = crypto.randomUUID()
|
|
const successClaim = await admin.rpc('claim_meeting_document_generation_v1', {
|
|
p_actor_id: userAId,
|
|
p_idempotency_key: successKey,
|
|
p_meeting_id: meetingId,
|
|
p_template_id: minutesA.id,
|
|
p_title: 'Atomic minutes',
|
|
p_model: 'claude-haiku-4-5-20251001',
|
|
})
|
|
assert.equal(successClaim.error, null, 'Owner could not claim generation')
|
|
checked(successClaim.data.claimed === true && successClaim.data.transcript.includes('rollback'), 'Claim did not bind the authenticated transcript')
|
|
|
|
const successCommit = await admin.rpc('commit_meeting_document_generation_v1', {
|
|
p_actor_id: userAId,
|
|
p_idempotency_key: successKey,
|
|
p_content: '## 요약\n모든 롤백 검증 후 배포한다.',
|
|
p_latency_ms: 42,
|
|
p_input_tokens: 10,
|
|
p_output_tokens: 8,
|
|
})
|
|
assert.equal(successCommit.error, null, 'Atomic generation commit failed')
|
|
const generatedDocumentId = successCommit.data.document.id
|
|
checked(
|
|
await countRows('meeting_documents', { id: generatedDocumentId }) === 1
|
|
&& await countRows('meeting_document_generation_audit', { idempotency_key: successKey }) === 1
|
|
&& await countRows('daily_usage', { user_id: userAId, feature: 'llm_haiku' }) === 1,
|
|
'Document, usage, and audit were not committed together',
|
|
)
|
|
|
|
const idempotentCommit = await admin.rpc('commit_meeting_document_generation_v1', {
|
|
p_actor_id: userAId,
|
|
p_idempotency_key: successKey,
|
|
p_content: 'This different content must never replace the committed document.',
|
|
p_latency_ms: 99,
|
|
p_input_tokens: 99,
|
|
p_output_tokens: 99,
|
|
})
|
|
assert.equal(idempotentCommit.error, null)
|
|
checked(
|
|
idempotentCommit.data.idempotent === true
|
|
&& idempotentCommit.data.document.id === generatedDocumentId
|
|
&& await countRows('meeting_document_generation_audit', { idempotency_key: successKey }) === 1,
|
|
'Idempotent retry duplicated or replaced the committed document',
|
|
)
|
|
|
|
const rollbackKey = crypto.randomUUID()
|
|
const rollbackClaim = await admin.rpc('claim_meeting_document_generation_v1', {
|
|
p_actor_id: userAId,
|
|
p_idempotency_key: rollbackKey,
|
|
p_meeting_id: meetingId,
|
|
p_template_id: minutesA.id,
|
|
p_title: 'Rollback proof',
|
|
p_model: 'claude-haiku-4-5-20251001',
|
|
})
|
|
assert.equal(rollbackClaim.error, null)
|
|
const rollbackCommit = await admin.rpc('commit_meeting_document_generation_v1', {
|
|
p_actor_id: userAId,
|
|
p_idempotency_key: rollbackKey,
|
|
p_content: '',
|
|
p_latency_ms: 1,
|
|
p_input_tokens: 1,
|
|
p_output_tokens: 1,
|
|
})
|
|
checked(rollbackCommit.error?.code === '22023', 'Invalid provider output was accepted')
|
|
checked(
|
|
await countRows('meeting_documents', { generation_idempotency_key: rollbackKey }) === 0
|
|
&& await countRows('meeting_document_generation_audit', { idempotency_key: rollbackKey }) === 0
|
|
&& (await admin.from('daily_usage').select('count').eq('user_id', userAId).eq('feature', 'llm_haiku').single()).data.count === 1,
|
|
'Failed atomic commit left document, audit, or usage side effects',
|
|
)
|
|
const failureMark = await admin.rpc('fail_meeting_document_generation_v1', {
|
|
p_actor_id: userAId,
|
|
p_idempotency_key: rollbackKey,
|
|
p_error_code: 'provider_invalid_response',
|
|
})
|
|
assert.equal(failureMark.error, null)
|
|
checked(failureMark.data === true, 'Failed request was not terminally marked')
|
|
|
|
const providerUnavailableResponse = await fetch(`${supabaseUrl}/functions/v1/generate-meeting-document`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${userA.session.access_token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
meetingId,
|
|
templateId: minutesA.id,
|
|
idempotencyKey: crypto.randomUUID(),
|
|
title: 'Provider fail-closed proof',
|
|
}),
|
|
})
|
|
const providerUnavailableBody = await providerUnavailableResponse.json().catch(() => null)
|
|
checked(
|
|
providerUnavailableResponse.status === 503
|
|
&& providerUnavailableBody?.error === 'provider_unavailable',
|
|
`Local provider-unavailable path did not fail closed with a stable error: ${providerUnavailableResponse.status} ${JSON.stringify(providerUnavailableBody)}`,
|
|
)
|
|
|
|
console.log(`templates/memos local integration passed: ${assertions} assertions`)
|
|
} finally {
|
|
await Promise.allSettled(createdUserIds.map((userId) => admin.auth.admin.deleteUser(userId)))
|
|
}
|