520 lines
16 KiB
JavaScript
520 lines
16 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const postgres = require('postgres');
|
|
|
|
const FIXTURE_USERS = {
|
|
superAdmin: process.env.SUPER_ADMIN_EMAIL || 'admin@allaos.com',
|
|
admin: 'task-h1-admin@local.test',
|
|
pdfUser: 'task-h1-pdf-user@local.test',
|
|
viewer: 'task-h1-viewer@local.test'
|
|
};
|
|
|
|
const PASSWORDS = {
|
|
superAdmin: process.env.SUPER_ADMIN_PASSWORD || 'A3YH2Xflw1@allaos',
|
|
fixture: 'TaskH1Pass!2026'
|
|
};
|
|
|
|
function loadEnvFile(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return;
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
|
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
|
|
if (!line || line.startsWith('#')) {
|
|
continue;
|
|
}
|
|
|
|
const separatorIndex = line.indexOf('=');
|
|
|
|
if (separatorIndex === -1) {
|
|
continue;
|
|
}
|
|
|
|
const key = line.slice(0, separatorIndex).trim();
|
|
let value = line.slice(separatorIndex + 1).trim();
|
|
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
if (!(key in process.env)) {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadLocalEnv() {
|
|
loadEnvFile(path.resolve(process.cwd(), '.env.local'));
|
|
loadEnvFile(path.resolve(process.cwd(), '.env'));
|
|
}
|
|
|
|
function requireEnv(name) {
|
|
const value = process.env[name]?.trim();
|
|
|
|
if (!value) {
|
|
throw new Error(`Missing required environment variable: ${name}`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function extractCookies(response) {
|
|
const cookieMap = new Map();
|
|
const setCookies = typeof response.headers.getSetCookie === 'function'
|
|
? response.headers.getSetCookie()
|
|
: response.headers.get('set-cookie')
|
|
? [response.headers.get('set-cookie')]
|
|
: [];
|
|
|
|
for (const rawCookie of setCookies) {
|
|
const [pair] = rawCookie.split(';', 1);
|
|
const separatorIndex = pair.indexOf('=');
|
|
|
|
if (separatorIndex === -1) {
|
|
continue;
|
|
}
|
|
|
|
cookieMap.set(pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1));
|
|
}
|
|
|
|
return cookieMap;
|
|
}
|
|
|
|
function mergeCookieMaps(base, next) {
|
|
const merged = new Map(base);
|
|
|
|
for (const [key, value] of next.entries()) {
|
|
merged.set(key, value);
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function toCookieHeader(cookieMap) {
|
|
return [...cookieMap.entries()].map(([key, value]) => `${key}=${value}`).join('; ');
|
|
}
|
|
|
|
async function signIn(baseUrl, email, password) {
|
|
const csrfResponse = await fetch(`${baseUrl}/api/auth/csrf`);
|
|
assert(csrfResponse.ok, `Unable to fetch CSRF token for ${email}`);
|
|
const csrfPayload = await csrfResponse.json();
|
|
const cookies = extractCookies(csrfResponse);
|
|
const body = new URLSearchParams({
|
|
email,
|
|
password,
|
|
csrfToken: csrfPayload.csrfToken,
|
|
callbackUrl: `${baseUrl}/dashboard`,
|
|
json: 'true'
|
|
});
|
|
const signInResponse = await fetch(`${baseUrl}/api/auth/callback/credentials?json=true`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Cookie: toCookieHeader(cookies)
|
|
},
|
|
body: body.toString(),
|
|
redirect: 'manual'
|
|
});
|
|
|
|
assert(
|
|
signInResponse.status === 200 || signInResponse.status === 302,
|
|
`Unexpected sign-in status ${signInResponse.status} for ${email}`
|
|
);
|
|
|
|
const mergedCookies = mergeCookieMaps(cookies, extractCookies(signInResponse));
|
|
assert(mergedCookies.size > 0, `Sign-in did not return cookies for ${email}`);
|
|
|
|
return mergedCookies;
|
|
}
|
|
|
|
async function fetchWithAuth(baseUrl, cookieMap, pathname, init = {}) {
|
|
const response = await fetch(`${baseUrl}${pathname}`, {
|
|
...init,
|
|
headers: {
|
|
...(init.headers || {}),
|
|
Cookie: toCookieHeader(cookieMap)
|
|
},
|
|
redirect: 'manual'
|
|
});
|
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
const contentType = response.headers.get('content-type');
|
|
const disposition = response.headers.get('content-disposition');
|
|
let json = null;
|
|
|
|
if (contentType?.includes('application/json')) {
|
|
json = JSON.parse(bytes.toString('utf8'));
|
|
}
|
|
|
|
return {
|
|
status: response.status,
|
|
contentType,
|
|
disposition,
|
|
bytes,
|
|
json
|
|
};
|
|
}
|
|
|
|
function estimatePageCount(buffer) {
|
|
const text = buffer.toString('latin1');
|
|
|
|
return {
|
|
startsWithPdf: text.startsWith('%PDF-'),
|
|
byteLength: buffer.length
|
|
};
|
|
}
|
|
|
|
async function queryFixtureState(sql, approvedQuotationId) {
|
|
const quotation =
|
|
(
|
|
await sql`
|
|
select
|
|
id,
|
|
organization_id,
|
|
code,
|
|
approved_at,
|
|
approved_pdf_url,
|
|
approved_snapshot,
|
|
approved_template_version_id
|
|
from crm_quotations
|
|
where id = ${approvedQuotationId}
|
|
limit 1
|
|
`
|
|
)[0] ?? null;
|
|
|
|
assert(quotation, 'Approved quotation fixture not found in DB');
|
|
|
|
const [attachment, audit] = await Promise.all([
|
|
sql`
|
|
select
|
|
id,
|
|
file_name,
|
|
file_path,
|
|
file_type,
|
|
file_size,
|
|
description
|
|
from crm_quotation_attachments
|
|
where quotation_id = ${approvedQuotationId}
|
|
and deleted_at is null
|
|
order by created_at desc
|
|
limit 1
|
|
`.then((rows) => rows[0] ?? null),
|
|
sql`
|
|
select
|
|
id,
|
|
entity_type,
|
|
entity_id,
|
|
action,
|
|
created_at
|
|
from tr_audit_logs
|
|
where organization_id = ${quotation.organization_id}
|
|
and entity_type = 'crm_quotation_pdf_generate_approved'
|
|
and entity_id = ${approvedQuotationId}
|
|
order by created_at desc
|
|
limit 1
|
|
`.then((rows) => rows[0] ?? null)
|
|
]);
|
|
|
|
return {
|
|
quotation,
|
|
attachment,
|
|
audit
|
|
};
|
|
}
|
|
|
|
async function queryTemplatePageCount(sql, templateVersionId) {
|
|
const version =
|
|
(
|
|
await sql`
|
|
select schema_json
|
|
from crm_document_template_versions
|
|
where id = ${templateVersionId}
|
|
limit 1
|
|
`
|
|
)[0] ?? null;
|
|
|
|
const schemaJson =
|
|
typeof version?.schema_json === 'string'
|
|
? JSON.parse(version.schema_json)
|
|
: version?.schema_json ?? null;
|
|
const schemas = schemaJson?.schemas;
|
|
|
|
return Array.isArray(schemas) ? schemas.length : 0;
|
|
}
|
|
|
|
async function main() {
|
|
loadLocalEnv();
|
|
|
|
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
const sql = postgres(requireEnv('DATABASE_URL'), { prepare: false });
|
|
|
|
try {
|
|
const approvedQuotation =
|
|
(
|
|
await sql`
|
|
select id, code
|
|
from crm_quotations
|
|
where code = 'QT-TASK-H1-APPROVED'
|
|
limit 1
|
|
`
|
|
)[0] ?? null;
|
|
const draftQuotation =
|
|
(
|
|
await sql`
|
|
select id, code
|
|
from crm_quotations
|
|
where code = 'QT-TASK-H1-DRAFT'
|
|
limit 1
|
|
`
|
|
)[0] ?? null;
|
|
|
|
assert(approvedQuotation, 'Approved fixture quotation is missing');
|
|
assert(draftQuotation, 'Draft fixture quotation is missing');
|
|
|
|
const superAdminCookies = await signIn(baseUrl, FIXTURE_USERS.superAdmin, PASSWORDS.superAdmin);
|
|
const adminCookies = await signIn(baseUrl, FIXTURE_USERS.admin, PASSWORDS.fixture);
|
|
const pdfUserCookies = await signIn(baseUrl, FIXTURE_USERS.pdfUser, PASSWORDS.fixture);
|
|
const viewerCookies = await signIn(baseUrl, FIXTURE_USERS.viewer, PASSWORDS.fixture);
|
|
|
|
const preview = await fetchWithAuth(
|
|
baseUrl,
|
|
superAdminCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
|
);
|
|
assert(preview.status === 200, `Preview API failed with ${preview.status}`);
|
|
assert(preview.contentType?.includes('application/pdf'), 'Preview did not return PDF');
|
|
assert(preview.disposition?.startsWith('inline;'), 'Preview was not inline');
|
|
|
|
const download = await fetchWithAuth(
|
|
baseUrl,
|
|
superAdminCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/pdf-download`
|
|
);
|
|
assert(download.status === 200, `Download API failed with ${download.status}`);
|
|
assert(download.contentType?.includes('application/pdf'), 'Download did not return PDF');
|
|
assert(download.disposition?.startsWith('attachment;'), 'Download was not attachment');
|
|
|
|
const generateApproved = await fetchWithAuth(
|
|
baseUrl,
|
|
superAdminCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/approved-pdf`,
|
|
{ method: 'POST' }
|
|
);
|
|
assert(
|
|
generateApproved.status === 200,
|
|
`Approved PDF POST failed with ${generateApproved.status}`
|
|
);
|
|
assert(
|
|
generateApproved.disposition?.startsWith('attachment;'),
|
|
'Approved PDF POST was not attachment'
|
|
);
|
|
|
|
const approvedGet = await fetchWithAuth(
|
|
baseUrl,
|
|
superAdminCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/approved-pdf`
|
|
);
|
|
assert(approvedGet.status === 200, `Approved PDF GET failed with ${approvedGet.status}`);
|
|
assert(approvedGet.disposition?.startsWith('inline;'), 'Approved PDF GET was not inline');
|
|
|
|
const nonApprovedGenerate = await fetchWithAuth(
|
|
baseUrl,
|
|
superAdminCookies,
|
|
`/api/crm/quotations/${draftQuotation.id}/approved-pdf`,
|
|
{ method: 'POST' }
|
|
);
|
|
assert(
|
|
nonApprovedGenerate.status === 400,
|
|
`Draft quotation POST expected 400, got ${nonApprovedGenerate.status}`
|
|
);
|
|
|
|
const adminPreview = await fetchWithAuth(
|
|
baseUrl,
|
|
adminCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
|
);
|
|
const pdfUserPreview = await fetchWithAuth(
|
|
baseUrl,
|
|
pdfUserCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
|
);
|
|
const viewerPreview = await fetchWithAuth(
|
|
baseUrl,
|
|
viewerCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/pdf-preview`
|
|
);
|
|
const viewerGenerate = await fetchWithAuth(
|
|
baseUrl,
|
|
viewerCookies,
|
|
`/api/crm/quotations/${approvedQuotation.id}/approved-pdf`,
|
|
{ method: 'POST' }
|
|
);
|
|
|
|
assert(adminPreview.status === 200, `Admin preview expected 200, got ${adminPreview.status}`);
|
|
assert(
|
|
pdfUserPreview.status === 200,
|
|
`Regular permitted user preview expected 200, got ${pdfUserPreview.status}`
|
|
);
|
|
assert(
|
|
viewerPreview.status === 403,
|
|
`Regular user without preview permission expected 403, got ${viewerPreview.status}`
|
|
);
|
|
assert(
|
|
viewerGenerate.status === 403,
|
|
`Regular user without generate permission expected 403, got ${viewerGenerate.status}`
|
|
);
|
|
|
|
const persistence = await queryFixtureState(sql, approvedQuotation.id);
|
|
assert(persistence.quotation.approved_pdf_url, 'approvedPdfUrl was not saved');
|
|
assert(
|
|
persistence.quotation.approved_template_version_id,
|
|
'approvedTemplateVersionId was not saved'
|
|
);
|
|
assert(persistence.quotation.approved_snapshot, 'approvedSnapshot was not saved');
|
|
assert(persistence.attachment, 'Approved PDF attachment metadata was not created');
|
|
assert(persistence.audit, 'Approved PDF audit log was not created');
|
|
|
|
const absoluteStoredPath = path.join(
|
|
process.cwd(),
|
|
'public',
|
|
persistence.quotation.approved_pdf_url.replace(/^\//, '')
|
|
);
|
|
assert(fs.existsSync(absoluteStoredPath), 'Stored approved PDF file is missing on disk');
|
|
|
|
const pdfShape = estimatePageCount(approvedGet.bytes);
|
|
const approvedSnapshot = persistence.quotation.approved_snapshot;
|
|
const templatePageCount = await queryTemplatePageCount(
|
|
sql,
|
|
persistence.quotation.approved_template_version_id
|
|
);
|
|
|
|
assert(pdfShape.startsWithPdf, 'Approved PDF response does not start with %PDF header');
|
|
assert(pdfShape.byteLength > 1000, `Approved PDF size looks too small: ${pdfShape.byteLength}`);
|
|
assert(templatePageCount >= 1, `Template page count is not reasonable: ${templatePageCount}`);
|
|
assert(
|
|
approvedSnapshot.documentData?.customer?.name,
|
|
'approvedSnapshot is missing customer block data'
|
|
);
|
|
assert(
|
|
approvedSnapshot.documentData?.quotation?.code === approvedQuotation.code,
|
|
'approvedSnapshot is missing quotation code'
|
|
);
|
|
assert(
|
|
Array.isArray(approvedSnapshot.documentData?.items) &&
|
|
approvedSnapshot.documentData.items.length >= 1,
|
|
'approvedSnapshot is missing item table data'
|
|
);
|
|
assert(
|
|
typeof approvedSnapshot.documentData?.quotation?.totalAmount === 'number',
|
|
'approvedSnapshot is missing total amount'
|
|
);
|
|
assert(
|
|
approvedSnapshot.documentData?.topics?.scope?.length >= 1,
|
|
'approvedSnapshot is missing scope topic data'
|
|
);
|
|
assert(
|
|
approvedSnapshot.documentData?.topics?.exclusion?.length >= 1,
|
|
'approvedSnapshot is missing exclusion topic data'
|
|
);
|
|
assert(
|
|
approvedSnapshot.documentData?.topics?.payment?.length >= 1,
|
|
'approvedSnapshot is missing payment topic data'
|
|
);
|
|
assert(
|
|
approvedSnapshot.documentData?.approval?.approvers?.length >= 1,
|
|
'approvedSnapshot is missing approval block data'
|
|
);
|
|
assert(
|
|
Object.values(approvedSnapshot.documentData?.signatures ?? {}).some(Boolean),
|
|
'approvedSnapshot is missing signature placeholder data'
|
|
);
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
smokeTestRecord: {
|
|
approvedQuotationId: approvedQuotation.id,
|
|
approvedQuotationCode: approvedQuotation.code,
|
|
draftQuotationId: draftQuotation.id,
|
|
draftQuotationCode: draftQuotation.code
|
|
},
|
|
apiChecks: {
|
|
preview: {
|
|
status: preview.status,
|
|
disposition: preview.disposition,
|
|
bytes: preview.bytes.length
|
|
},
|
|
download: {
|
|
status: download.status,
|
|
disposition: download.disposition,
|
|
bytes: download.bytes.length
|
|
},
|
|
approvedPost: {
|
|
status: generateApproved.status,
|
|
disposition: generateApproved.disposition,
|
|
bytes: generateApproved.bytes.length
|
|
},
|
|
approvedGet: {
|
|
status: approvedGet.status,
|
|
disposition: approvedGet.disposition,
|
|
bytes: approvedGet.bytes.length
|
|
},
|
|
nonApprovedPost: {
|
|
status: nonApprovedGenerate.status,
|
|
message: nonApprovedGenerate.json?.message ?? null
|
|
}
|
|
},
|
|
permissionChecks: {
|
|
superAdminPreview: preview.status,
|
|
adminPreview: adminPreview.status,
|
|
regularUserWithPermissionPreview: pdfUserPreview.status,
|
|
regularUserWithoutPermissionPreview: viewerPreview.status,
|
|
regularUserWithoutPermissionGenerate: viewerGenerate.status
|
|
},
|
|
persistence: {
|
|
approvedPdfUrl: persistence.quotation.approved_pdf_url,
|
|
approvedTemplateVersionId: persistence.quotation.approved_template_version_id,
|
|
attachmentId: persistence.attachment.id,
|
|
auditId: persistence.audit.id,
|
|
storedFileExists: true
|
|
},
|
|
outputValidation: {
|
|
templatePageCount,
|
|
pdfByteLength: pdfShape.byteLength,
|
|
startsWithPdfHeader: pdfShape.startsWithPdf,
|
|
customerName: approvedSnapshot.documentData.customer.name,
|
|
quotationCode: approvedSnapshot.documentData.quotation.code,
|
|
itemCount: approvedSnapshot.documentData.items.length,
|
|
totalAmount: approvedSnapshot.documentData.quotation.totalAmount,
|
|
scopeTopicCount: approvedSnapshot.documentData.topics.scope.length,
|
|
exclusionTopicCount: approvedSnapshot.documentData.topics.exclusion.length,
|
|
paymentTopicCount: approvedSnapshot.documentData.topics.payment.length,
|
|
approvalStepCount: approvedSnapshot.documentData.approval.approvers.length
|
|
}
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
} finally {
|
|
await sql.end();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('[verify-task-h1] Failed:', error.message);
|
|
process.exitCode = 1;
|
|
});
|