#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; const DEFAULT_BASE_URL = "https://oa.hyinda.com"; const DEFAULT_INDEX = "/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引/内部资料保密索引_含真实路径_不得外发.csv"; const DEFAULT_ENV_FILE = path.join(process.env.HOME || "", ".hyb-oa/live-smoke.env"); function argValue(name, fallback = null) { const prefix = `${name}=`; const found = process.argv.slice(2).find((item) => item === name || item.startsWith(prefix)); if (!found) return fallback; if (found === name) return "1"; return found.slice(prefix.length); } function hasArg(name) { return process.argv.slice(2).includes(name); } function parseEnvLine(line) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) return null; const index = trimmed.indexOf("="); if (index < 1) return null; const key = trimmed.slice(0, index).trim(); let value = trimmed.slice(index + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } return { key, value }; } function loadEnvFile(filePath) { if (!filePath || !fs.existsSync(filePath)) return; for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) { const parsed = parseEnvLine(line); if (!parsed) continue; if (process.env[parsed.key] == null) { process.env[parsed.key] = parsed.value; } } } function parseCsvLine(line) { const cells = []; let current = ""; let quoted = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; const next = line[index + 1]; if (quoted && char === '"' && next === '"') { current += '"'; index += 1; continue; } if (char === '"') { quoted = !quoted; continue; } if (!quoted && char === ",") { cells.push(current); current = ""; continue; } current += char; } cells.push(current); return cells; } function parseCsv(content) { const lines = content.replace(/^\uFEFF/, "").split(/\r?\n/).filter(Boolean); if (!lines.length) return []; const headers = parseCsvLine(lines[0]).map((item) => item.trim()); return lines.slice(1).map((line) => { const values = parseCsvLine(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])); }); } function mapSensitivity(level) { if (level === "S4") return "strictly_confidential"; if (level === "S3") return "confidential"; if (level === "S1") return "public_candidate"; return "internal"; } function buildItems(input, limit) { const rows = parseCsv(fs.readFileSync(input, "utf8")); const selected = rows.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 3); return { totalRows: rows.length, items: selected.map((row, index) => ({ code: row.code || null, name: row.doc_type ? `${row.doc_type}-${row.code || index + 1}` : `电子资料-${row.code || index + 1}`, extension: row.extension || null, docType: row.doc_type || null, sourceHash: row.sha256_12 || null, sensitivityLevel: mapSensitivity(row.level), detectedCategories: row.detected_categories || null, requiredRedaction: row.required_redaction || null, verificationNo: null, })), }; } async function fetchJson(url, init = {}) { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(30_000), }); const text = await response.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: response.ok, status: response.status, json, text: text.slice(0, 200) }; } async function login(baseUrl) { const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(); const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(); if (!identifier || !password) { throw new Error("missing LIVE_SMOKE_ADMIN_IDENTIFIER/PASSWORD"); } const response = await fetchJson(`${baseUrl}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": "asset1d-ea-doc-import", }, body: JSON.stringify({ identifier, password }), }); const accessToken = response.json?.data?.accessToken; if (!response.ok || typeof accessToken !== "string" || !accessToken) { throw new Error(`admin login failed: HTTP ${response.status} ${response.json?.error?.code ?? ""}`.trim()); } return accessToken; } async function apiGet(baseUrl, token, urlPath) { const response = await fetchJson(`${baseUrl}${urlPath}`, { headers: { accept: "application/json", authorization: `Bearer ${token}` }, }); if (!response.ok) { throw new Error(`GET ${urlPath} failed: HTTP ${response.status} ${response.json?.error?.code ?? ""}`.trim()); } return response.json?.data; } async function apiPost(baseUrl, token, urlPath, data) { const response = await fetchJson(`${baseUrl}${urlPath}`, { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json", }, body: JSON.stringify(data), }); if (!response.ok) { const code = response.json?.error?.code ?? ""; const details = JSON.stringify(response.json?.error?.details ?? {}).slice(0, 240); throw new Error(`POST ${urlPath} failed: HTTP ${response.status} ${code} ${details}`.trim()); } return response.json?.data; } function chooseAssetCategory(items, requestedCode) { const active = items.filter((item) => item.enabled && !item.archivedAt); if (requestedCode) { const matched = active.find((item) => item.code === requestedCode); if (!matched) throw new Error(`asset category not found or inactive: ${requestedCode}`); return matched; } const asset1d = active .filter((item) => /^ASSET1D_/i.test(item.code)) .sort((a, b) => String(b.createdAt || b.code).localeCompare(String(a.createdAt || a.code))); if (asset1d[0]) return asset1d[0]; if (active[0]) return active[0]; throw new Error("no active asset category available"); } function chooseLegalEntity(items, requestedCode) { const active = items.filter((item) => item.enabled && !item.archivedAt); if (requestedCode) { const matched = active.find((item) => item.code === requestedCode); if (!matched) throw new Error(`legal entity not found or inactive: ${requestedCode}`); return matched; } if (active[0]) return active[0]; throw new Error("no active legal entity available"); } function chooseCostCenter(items, legalEntityId, requestedCode) { const active = items.filter((item) => item.enabled && !item.archivedAt && item.legalEntityId === legalEntityId); if (requestedCode) { const matched = active.find((item) => item.code === requestedCode); if (!matched) throw new Error(`cost center not found or inactive: ${requestedCode}`); return matched; } if (active[0]) return active[0]; throw new Error("no active cost center available for selected legal entity"); } function redactImportItems(items) { return items.map((item) => ({ code: item.code, status: item.status, assetNo: item.assetNo ?? null, sourceHash: item.sourceHash ?? null, })); } async function verifyAssets(baseUrl, token, importedItems) { const verified = []; for (const item of importedItems) { if (!item.assetNo) continue; const data = await apiGet( baseUrl, token, `/api/assets?assetType=electronic_document&q=${encodeURIComponent(item.assetNo)}`, ); const found = (data?.items ?? []).find((candidate) => candidate.assetNo === item.assetNo); verified.push({ assetNo: item.assetNo, status: found ? "PASS" : "FAIL", assetType: found?.assetType ?? null, sensitivityLevel: found?.sensitivityLevel ?? null, }); } return verified; } async function verifyAudit(baseUrl, token, sinceIso) { const query = new URLSearchParams({ page: "1", pageSize: "50", targetType: "asset_card", dateFrom: sinceIso, }); const data = await apiGet(baseUrl, token, `/api/audit-logs?${query.toString()}`); const actions = (data?.items ?? []).map((item) => item.action); return { importedItems: actions.filter((action) => action === "ASSET_REDACTED_DOC_INDEX_IMPORTED").length, batchRecords: actions.filter((action) => action === "ASSET_REDACTED_DOC_INDEX_IMPORT_BATCH").length, }; } function privacySmoke(summaryText) { const patterns = [ /LIVE_SMOKE_[A-Z0-9_]*PASSWORD/i, /WECOM_[A-Z0-9_]*(SECRET|TOKEN)/i, /ASSET_INDEX_IMPORT_WRITE=1/i, /\/home\/shunli\/Projects\/faculty-daily-assistant\/公司\//, /1[3-9]\d{9}/, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, ]; return patterns.some((pattern) => pattern.test(summaryText)) ? "FAIL" : "PASS"; } async function main() { if (process.env.ASSET1D_SKIP_ENV_FILE !== "1") { loadEnvFile(argValue("--env-file", DEFAULT_ENV_FILE)); } const apply = hasArg("--apply"); if (apply && process.env.ASSET_INDEX_IMPORT_WRITE !== "1") { throw new Error("--apply requires local ASSET_INDEX_IMPORT_WRITE=1"); } const baseUrl = (argValue("--base-url", process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL) || DEFAULT_BASE_URL) .trim() .replace(/\/$/, ""); const input = argValue("--input", DEFAULT_INDEX); const limit = Number(argValue("--limit", "3")); const reportDir = path.resolve( argValue( "--report-dir", `artifacts/qa-flow/asset-redacted-index-import-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}`, ), ); fs.mkdirSync(reportDir, { recursive: true }); const checkedAt = new Date().toISOString(); const { totalRows, items } = buildItems(input, limit); const token = await login(baseUrl); const [categoryData, legalData] = await Promise.all([ apiGet(baseUrl, token, "/api/asset-categories?includeArchived=1"), apiGet(baseUrl, token, "/api/master-data/legal-entities?enabled=true"), ]); const category = chooseAssetCategory(categoryData?.items ?? [], argValue("--category-code")); const legalEntity = chooseLegalEntity(legalData?.items ?? [], argValue("--legal-entity-code")); const costCenterData = await apiGet( baseUrl, token, `/api/master-data/cost-centers?enabled=true&legalEntityId=${encodeURIComponent(legalEntity.id)}`, ); const costCenter = chooseCostCenter(costCenterData?.items ?? [], legalEntity.id, argValue("--cost-center-code")); const importStartedAt = new Date().toISOString(); const importResult = await apiPost(baseUrl, token, "/api/assets/import/redacted-doc-index", { dryRun: !apply, write: apply, categoryId: category.id, legalEntityId: legalEntity.id, costCenterId: costCenter.id, items, }); const resultItems = importResult?.items ?? []; const verifiedAssets = apply ? await verifyAssets(baseUrl, token, resultItems) : []; const audit = apply ? await verifyAudit(baseUrl, token, importStartedAt) : null; const summary = { issueId: "ASSET1D-6", status: apply ? "PASS_EA_DOC_IMPORT_READY" : "PASS_EA_DOC_IMPORT_DRY_RUN_READY", mode: apply ? "asset-redacted-index-import-write" : "asset-redacted-index-import-dry-run", baseUrl, checkedAt, totalRows, selectedRows: items.length, imported: importResult?.imported ?? 0, selectedMasterData: { categoryCode: category.code, legalEntityCode: legalEntity.code, costCenterCode: costCenter.code, }, items: redactImportItems(resultItems), verifiedAssets, audit, boundaries: { noFileUpload: true, noRawContent: true, noFullPathOutput: true, noSecretOutput: true, noPasswordOutput: true, noSourceBundleUpload: true, writeSwitchRequired: true, publicOaAuditRetained: apply, }, }; const summaryText = `${JSON.stringify(summary, null, 2)}\n`; summary.privacySmoke = privacySmoke(summaryText); fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); console.log(JSON.stringify(summary, null, 2)); if (summary.privacySmoke !== "PASS") process.exit(1); if (apply && (verifiedAssets.some((item) => item.status !== "PASS") || (audit?.importedItems ?? 0) < 1)) { process.exit(1); } } main().catch((error) => { console.error(`[asset-redacted-index-import][ERROR] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });