feat(oa): add permission-controlled shared finance remarks to done center
Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks. Add idempotent MySQL migration without automatic role grants, cross-end editors, isolated backend and frontend tests, and rollout requirements. Refs #7; platform validation and target database migration remain pending.
This commit is contained in:
1 parent
86df360f35
commit
0e64b413ce
15 files changed
+1188
-78
No files matched your search
@@ -0,0 +1,299 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { test } from 'node:test'
|
||||
import { parse, compileTemplate } from '@vue/compiler-sfc'
|
||||
import ts from 'typescript'
|
||||
import { ref, reactive, computed, proxyRefs } from 'vue'
|
||||
|
||||
const root = new URL('../../', import.meta.url)
|
||||
const fields = [
|
||||
'canManageFinanceRemark',
|
||||
'financeRemarkVisible',
|
||||
'financeRemarkLoading',
|
||||
'financeRemarkSaving',
|
||||
'financeRemarkText',
|
||||
'openFinanceRemark',
|
||||
'saveFinanceRemark',
|
||||
'cancelFinanceRemark',
|
||||
'clearFinanceAccess',
|
||||
'load',
|
||||
'rows'
|
||||
]
|
||||
function evaluate(source, deps, tail = '') {
|
||||
const withoutImports = source.replace(/import[\s\S]*?from\s*['"][^'"]+['"]/g, '')
|
||||
const js = ts.transpileModule(withoutImports + tail, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText
|
||||
const exports = {}
|
||||
return (
|
||||
new Function(...Object.keys(deps), 'exports', js)(...Object.values(deps), exports) ||
|
||||
exports.default ||
|
||||
exports
|
||||
)
|
||||
}
|
||||
function harness(platform) {
|
||||
const calls = []
|
||||
const hooks = {}
|
||||
const api = {
|
||||
list: {
|
||||
list: [{ id: 'task-1', instanceId: 'instance-1', financeRemark: '列表旧值' }],
|
||||
total: 1,
|
||||
canManageFinanceRemark: true
|
||||
},
|
||||
get: async () => ({ financeRemark: '最新备注' }),
|
||||
put: async () => ({ financeRemark: '' })
|
||||
}
|
||||
const deps = {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
defineOptions() {},
|
||||
watchDebounced() {},
|
||||
onMounted() {},
|
||||
onActivated() {},
|
||||
onDeactivated(fn) {
|
||||
hooks.hide = fn
|
||||
},
|
||||
useRouter: () => ({}),
|
||||
useMenuPerm: () => ({ hasMenuPerm: () => false }),
|
||||
useUserStore: () => ({}),
|
||||
ElMessage: { success() {}, error() {}, warning() {} },
|
||||
uni: { showToast() {}, navigateTo() {} },
|
||||
VanEmpty: {},
|
||||
getToken: () => 'token',
|
||||
forceRelogin() {},
|
||||
fetchOaTasks: async () => {
|
||||
calls.push(['list'])
|
||||
return api.list
|
||||
},
|
||||
fetchOaFinanceRemark: async (id) => {
|
||||
calls.push(['get', id])
|
||||
return api.get(id)
|
||||
},
|
||||
fetchUpdateOaFinanceRemark: async (id, text) => {
|
||||
calls.push(['put', id, text])
|
||||
return api.put(id, text)
|
||||
}
|
||||
}
|
||||
const path =
|
||||
platform === 'web'
|
||||
? 'web-admin/src/views/oa-approval/done/index.vue'
|
||||
: 'mobile-h5/pages/oa/done/index.vue'
|
||||
const { descriptor } = parse(readFileSync(new URL(path, root), 'utf8'))
|
||||
let vm
|
||||
if (platform === 'web') {
|
||||
vm = proxyRefs(
|
||||
evaluate(
|
||||
descriptor.scriptSetup.content,
|
||||
deps,
|
||||
'\nreturn {' +
|
||||
fields.map((k) => `${k}: typeof ${k} === 'undefined' ? undefined : ${k}`).join(',') +
|
||||
'}'
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const options = evaluate(descriptor.script.content, deps)
|
||||
vm = options.data()
|
||||
for (const [key, fn] of Object.entries(options.methods)) vm[key] = fn.bind(vm)
|
||||
hooks.hide = () => options.onHide?.call(vm)
|
||||
}
|
||||
const load = () => (platform === 'web' ? vm.load() : vm.loadList(true))
|
||||
const rows = () => (platform === 'web' ? vm.rows : vm.list)
|
||||
return { vm, calls, api, load, rows, hooks, descriptor }
|
||||
}
|
||||
for (const platform of ['web', 'mobile']) {
|
||||
test(`${platform}: capability gates access and opening gets latest instance value`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
assert.equal(h.vm.canManageFinanceRemark, true)
|
||||
await h.vm.openFinanceRemark(h.rows()[0])
|
||||
assert.deepEqual(h.calls.at(-1), ['get', 'instance-1'])
|
||||
assert.equal(h.vm.financeRemarkText, '最新备注')
|
||||
assert.equal(h.vm.financeRemarkVisible, true)
|
||||
})
|
||||
test(`${platform}: cancellation makes no write and discards late GET`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
let resolve
|
||||
h.api.get = () =>
|
||||
new Promise((r) => {
|
||||
resolve = r
|
||||
})
|
||||
const opening = h.vm.openFinanceRemark(h.rows()[0])
|
||||
h.vm.cancelFinanceRemark()
|
||||
resolve({ financeRemark: '不得重新显示' })
|
||||
await opening
|
||||
assert.equal(h.vm.financeRemarkVisible, false)
|
||||
assert.equal(h.vm.financeRemarkText, '')
|
||||
assert.equal(
|
||||
h.calls.some((c) => c[0] === 'put'),
|
||||
false
|
||||
)
|
||||
})
|
||||
test(`${platform}: save failure preserves input; clear succeeds and refreshes`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
await h.vm.openFinanceRemark(h.rows()[0])
|
||||
h.vm.financeRemarkText = '我的未保存输入'
|
||||
h.api.put = async () => {
|
||||
throw new Error('failure')
|
||||
}
|
||||
await h.vm.saveFinanceRemark()
|
||||
assert.equal(h.vm.financeRemarkText, '我的未保存输入')
|
||||
assert.equal(h.vm.financeRemarkVisible, true)
|
||||
h.api.put = async () => ({ financeRemark: '' })
|
||||
h.vm.financeRemarkText = ''
|
||||
await h.vm.saveFinanceRemark()
|
||||
assert.deepEqual(h.calls.slice(-2), [['put', 'instance-1', ''], ['list']])
|
||||
assert.equal(h.vm.financeRemarkVisible, false)
|
||||
})
|
||||
test(`${platform}: missing capability strips cached notes and prevents GET`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
await h.vm.openFinanceRemark(h.rows()[0])
|
||||
h.api.list = { list: [{ instanceId: 'instance-1', financeRemark: '敏感备注' }], total: 1 }
|
||||
await h.load()
|
||||
assert.equal(h.vm.canManageFinanceRemark, false)
|
||||
assert.equal(h.vm.financeRemarkVisible, false)
|
||||
assert.equal(h.vm.financeRemarkText, '')
|
||||
assert.equal('financeRemark' in h.rows()[0], false)
|
||||
const count = h.calls.length
|
||||
await h.vm.openFinanceRemark(h.rows()[0])
|
||||
assert.equal(h.calls.length, count)
|
||||
})
|
||||
test(`${platform}: hiding page clears notes and invalidates pending reads`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
let resolve
|
||||
h.api.get = () =>
|
||||
new Promise((r) => {
|
||||
resolve = r
|
||||
})
|
||||
const opening = h.vm.openFinanceRemark(h.rows()[0])
|
||||
h.hooks.hide()
|
||||
resolve({ financeRemark: '不得恢复的旧备注' })
|
||||
await opening
|
||||
assert.equal(h.vm.canManageFinanceRemark, false)
|
||||
assert.equal(h.vm.financeRemarkText, '')
|
||||
assert.equal('financeRemark' in h.rows()[0], false)
|
||||
})
|
||||
test(`${platform}: overlong input cannot send PUT`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
await h.vm.openFinanceRemark(h.rows()[0])
|
||||
h.vm.financeRemarkText = '字'.repeat(513)
|
||||
await h.vm.saveFinanceRemark()
|
||||
assert.equal(
|
||||
h.calls.some((c) => c[0] === 'put'),
|
||||
false
|
||||
)
|
||||
assert.equal(h.vm.financeRemarkVisible, true)
|
||||
})
|
||||
test(`${platform}: saving while reading or already saving sends no duplicate PUT`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
let resolveGet
|
||||
h.api.get = () =>
|
||||
new Promise((r) => {
|
||||
resolveGet = r
|
||||
})
|
||||
const opening = h.vm.openFinanceRemark(h.rows()[0])
|
||||
await h.vm.saveFinanceRemark()
|
||||
assert.equal(
|
||||
h.calls.some((c) => c[0] === 'put'),
|
||||
false
|
||||
)
|
||||
resolveGet({ financeRemark: '备注' })
|
||||
await opening
|
||||
let resolvePut
|
||||
h.api.put = () =>
|
||||
new Promise((r) => {
|
||||
resolvePut = r
|
||||
})
|
||||
const saving = h.vm.saveFinanceRemark()
|
||||
await h.vm.saveFinanceRemark()
|
||||
assert.equal(h.calls.filter((c) => c[0] === 'put').length, 1)
|
||||
resolvePut({ financeRemark: '备注' })
|
||||
await saving
|
||||
})
|
||||
test(`${platform}: late list response cannot restore permission after hiding`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
let resolve
|
||||
const previous = h.api.list
|
||||
h.api.list = new Promise((r) => {
|
||||
resolve = r
|
||||
})
|
||||
const loading = h.load()
|
||||
h.hooks.hide()
|
||||
resolve(previous)
|
||||
await loading
|
||||
assert.equal(h.vm.canManageFinanceRemark, false)
|
||||
assert.equal('financeRemark' in h.rows()[0], false)
|
||||
})
|
||||
test(`${platform}: failed latest-value request cannot save stale list text`, async () => {
|
||||
const h = harness(platform)
|
||||
await h.load()
|
||||
h.api.get = async () => {
|
||||
throw new Error('forbidden')
|
||||
}
|
||||
await h.vm.openFinanceRemark(h.rows()[0])
|
||||
assert.equal(h.vm.financeRemarkVisible, false)
|
||||
assert.equal(h.vm.financeRemarkText, '')
|
||||
await h.vm.saveFinanceRemark()
|
||||
assert.equal(
|
||||
h.calls.some((c) => c[0] === 'put'),
|
||||
false
|
||||
)
|
||||
})
|
||||
test(`${platform}: template compiles with plain text and gated edit controls`, () => {
|
||||
const { descriptor } = harness(platform)
|
||||
const result = compileTemplate({
|
||||
source: descriptor.template.content,
|
||||
filename: 'done.vue',
|
||||
id: platform
|
||||
})
|
||||
assert.deepEqual(result.errors, [])
|
||||
assert.match(result.code, /canManageFinanceRemark/)
|
||||
assert.match(result.code, /toDisplayString\([^)]*financeRemark/)
|
||||
assert.doesNotMatch(result.code, /innerHTML/)
|
||||
if (platform === 'mobile')
|
||||
assert.match(result.code, /withModifiers[^\n]*openFinanceRemark[^\n]*[\s\S]*?\["stop"\]/)
|
||||
})
|
||||
}
|
||||
for (const platform of ['web', 'mobile']) {
|
||||
test(`${platform}: finance API uses dedicated authenticated instance endpoint`, async () => {
|
||||
const calls = []
|
||||
const request = (config) => {
|
||||
calls.push(config)
|
||||
return Promise.resolve({})
|
||||
}
|
||||
request.get = (config) => request({ ...config, method: 'GET' })
|
||||
request.put = (config) => request({ ...config, method: 'PUT' })
|
||||
const path =
|
||||
platform === 'web' ? 'web-admin/src/api/oaApproval.ts' : 'mobile-h5/utils/oa-api.js'
|
||||
const api = evaluate(readFileSync(new URL(path, root), 'utf8'), {
|
||||
request,
|
||||
get: (url, header) => request({ url, header, method: 'GET' }),
|
||||
getToken: () => 'token'
|
||||
})
|
||||
assert.equal(typeof api.fetchOaFinanceRemark, 'function')
|
||||
await api.fetchOaFinanceRemark('instance-1')
|
||||
await api.fetchUpdateOaFinanceRemark('instance-1', '')
|
||||
assert.equal(calls[0].method, 'GET')
|
||||
assert.equal(calls[1].method, 'PUT')
|
||||
assert.equal(calls[0].url, '/web/admin/oa-approval/instances/instance-1/finance-remark')
|
||||
assert.equal(calls[1].url, calls[0].url)
|
||||
assert.deepEqual(calls[1].data, { financeRemark: '' })
|
||||
if (platform === 'mobile') assert.equal(calls[1].header.Authorization, 'Bearer token')
|
||||
})
|
||||
}
|
||||
test('web: delayed refresh on an inactive cached page cannot restore finance data', async () => {
|
||||
const h = harness('web')
|
||||
await h.load()
|
||||
h.hooks.hide()
|
||||
const count = h.calls.length
|
||||
await h.load()
|
||||
assert.equal(h.vm.canManageFinanceRemark, false)
|
||||
assert.equal(h.calls.length, count)
|
||||
})
|
||||
Reference in new issue
Block a user