feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
// Pure UTS boundary tests. No network, database, real session or runtime installation.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
class JsonObject {
|
||||
constructor(value = {}) { Object.assign(this, value); }
|
||||
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
|
||||
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
|
||||
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
|
||||
}
|
||||
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([k, v]) => [k, json(v)]))) : value;
|
||||
let session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
let calls = [];
|
||||
let reply;
|
||||
class ApiError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
|
||||
const api = {
|
||||
ApiRequestError: ApiError,
|
||||
getSession: () => session,
|
||||
requestJson: async (url, method, payload) => { calls.push({ url, method, payload: JSON.parse(JSON.stringify(payload)) }); return json(await reply(url, method, payload)); }
|
||||
};
|
||||
const modules = {};
|
||||
const pages = [];
|
||||
const redirects = [];
|
||||
const styles = { pageStyle: () => '', contentStyle: () => '', statusStyle: () => '', bottomStyle: () => '' };
|
||||
let pageShow, pageHide, pageLoad;
|
||||
const pageUni = { showToast: () => {}, showModal: options => options.success?.({ confirm: true }), redirectTo: ({ url }) => redirects.push(url), navigateTo: ({ url }) => pages.push(url) };
|
||||
api.saveSession = (token, phone, userId, expiresAt) => { session = json({ token, phone, userId, expiresAt }); };
|
||||
api.API_BASE_URL = 'https://synthetic.invalid/api/v1';
|
||||
function load(relative) {
|
||||
if (modules[relative]) return modules[relative];
|
||||
const file = path.join(root, relative);
|
||||
if (!fs.existsSync(file)) return {};
|
||||
const output = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const exports = {};
|
||||
modules[relative] = exports;
|
||||
vm.runInNewContext(output, { exports, UTSJSONObject: JsonObject, Date, Math, Number, Error,
|
||||
require: name => name.endsWith('/api.uts') || name === './api.uts' ? api : load(path.posix.normalize(path.posix.join(path.posix.dirname(relative), name)))
|
||||
}, { filename: relative });
|
||||
return exports;
|
||||
}
|
||||
function componentPage(relative, bindings) {
|
||||
pageShow = null; pageHide = null; pageLoad = null;
|
||||
const source = fs.readFileSync(path.join(root, relative), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||||
const output = ts.transpileModule(source + '\nexports.__test = {' + bindings + '};', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const exports = {};
|
||||
vm.runInNewContext(output, { exports, UTSJSONObject: JsonObject, Date, Math, Number, Error, uni: pageUni,
|
||||
ref: value => ({ value }), watch: () => {}, onLoad: callback => { pageLoad = callback; }, onShow: callback => { pageShow = callback; }, onHide: callback => { pageHide = callback; }, onUnload: () => {}, onUnmounted: () => {},
|
||||
setInterval: () => 1, clearInterval: () => {},
|
||||
require: name => name === '@/services/api.uts' ? api : name === '@/services/layout.uts' ? { usePageLayout: () => styles } : name.endsWith('.uvue') ? {} : name === '@/services/launch.uts' ? { consumeInitialLaunch: () => {} } : load(name.replace(/^@\//, ''))
|
||||
}, { filename: relative });
|
||||
return exports.__test;
|
||||
}
|
||||
const loginPage = () => componentPage('packages/account/login/login.uvue', 'login, phone, password, agreed, errorMessage, loading, toggleAgreement: typeof toggleAgreement === "function" ? toggleAgreement : null, loadAgreements: typeof loadAgreements === "function" ? loadAgreements : null');
|
||||
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
|
||||
const route = load('services/agreements-route.uts');
|
||||
const service = load('services/agreements.uts');
|
||||
let checks = 0;
|
||||
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
|
||||
const hash = 'a'.repeat(64);
|
||||
const terms = { revisionId: '1', code: 'terms', version: '2026.10-v1', scope: 'account-terms', title: '合成用户协议', content: '合成全文\n第二段 <script>只是纯文本</script>', contentHash: hash, effectiveAt: '2026-10-01T00:00:00Z' };
|
||||
const consent = { consentId: '9', scope: 'account-terms', agreementCode: 'terms', agreementVersion: '2026.10-v1', contentHash: hash, grantedAt: '2026-10-12T00:00:00Z', revokedAt: null, canRevoke: false };
|
||||
(async () => {
|
||||
await test('unknown agreement code cannot produce a route', () => {
|
||||
assert.equal(typeof route.agreementRoute, 'function', 'agreement route validation is not implemented');
|
||||
for (const code of ['', 'unknown', 'terms?x=1', 'https://outside.invalid']) assert.equal(route.agreementRoute(code), '');
|
||||
});
|
||||
await test('versioned agreement links preserve the immutable version', () => {
|
||||
assert.equal(route.agreementRoute('terms', '2026.10-v1'), '/pages/agreement/agreement?code=terms&version=2026.10-v1');
|
||||
assert.equal(route.agreementRoute('privacy'), '/pages/agreement/agreement?code=privacy');
|
||||
for (const version of ['x&code=terms', '\n', 'x'.repeat(65), '../draft']) assert.equal(route.agreementRoute('terms', version), '');
|
||||
});
|
||||
await test('scope mapping separates account confirmations from optional grants', () => {
|
||||
assert.equal(route.agreementScope('terms'), 'account-terms');
|
||||
assert.equal(route.agreementScope('privacy'), 'account-privacy');
|
||||
assert.equal(route.agreementScope('document-use'), 'documents');
|
||||
assert.equal(route.agreementScope('data-authorization'), 'data');
|
||||
assert.equal(route.optionalConsentScope('account-privacy'), false);
|
||||
assert.equal(route.optionalConsentScope('documents'), true);
|
||||
});
|
||||
await test('BIGINT agreement ids stay as strings and reject overflow', () => {
|
||||
for (const value of ['1', '9223372036854775807']) assert.equal(route.agreementRecordIdValid(value), true);
|
||||
for (const value of ['0', '01', '-1', '9223372036854775808', '99999999999999999999']) assert.equal(route.agreementRecordIdValid(value), false);
|
||||
});
|
||||
await test('account guard rejects another account, new token and anonymous state', () => {
|
||||
assert.equal(route.sameConsentSession('a', '1', 'a', '1'), true);
|
||||
for (const args of [['a', '1', 'b', '1'], ['a', '1', 'a', '2'], ['', '', '', ''], ['a', '1', '', '']]) assert.equal(route.sameConsentSession(...args), false);
|
||||
});
|
||||
await test('public fetch emits only a validated code and requested version', async () => {
|
||||
calls = []; reply = () => terms;
|
||||
const result = await service.fetchAgreement('terms', '2026.10-v1');
|
||||
assert.equal(result.content, terms.content);
|
||||
assert.deepEqual(calls, [{ url: '/agreements/terms', method: 'GET', payload: { version: '2026.10-v1' } }]);
|
||||
await assert.rejects(service.fetchAgreement('unknown'));
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
await test('wrong version, scope, hash and invalid timestamp cannot become seen agreement', async () => {
|
||||
for (const patch of [{ version: 'old' }, { code: 'privacy' }, { scope: 'data' }, { contentHash: 'bad' }, { content: '' }, { effectiveAt: 'bad' }]) {
|
||||
reply = () => ({ ...terms, ...patch });
|
||||
await assert.rejects(service.fetchAgreement('terms', '2026.10-v1'));
|
||||
}
|
||||
});
|
||||
await test('not-published detection does not hide a network or server failure', () => {
|
||||
assert.equal(service.agreementUnavailable(new ApiError('未发布', 404, 'AGREEMENT_NOT_AVAILABLE')), true);
|
||||
for (const error of [new Error('network'), new ApiError('server', 503, 'AGREEMENT_NOT_AVAILABLE'), new ApiError('not found', 404, 'OTHER')]) assert.equal(service.agreementUnavailable(error), false);
|
||||
});
|
||||
await test('grant sends the exact seen version and stable request key', async () => {
|
||||
calls = []; reply = () => consent;
|
||||
const result = await service.grantConsent(terms, 'consent_request_123');
|
||||
assert.equal(result.consentId, '9');
|
||||
assert.deepEqual(calls, [{ url: '/consents', method: 'POST', payload: { scope: 'account-terms', agreementCode: 'terms', agreementVersion: '2026.10-v1', requestId: 'consent_request_123' } }]);
|
||||
});
|
||||
await test('anonymous or malformed consent cannot reach the network', async () => {
|
||||
calls = []; session = null;
|
||||
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
await assert.rejects(service.grantConsent(terms, 'bad'));
|
||||
await assert.rejects(service.grantConsent({ ...terms, scope: 'data' }, 'consent_request_123'));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
await test('consent request keys require the server minimum of sixteen characters', async () => {
|
||||
calls = []; reply = () => consent;
|
||||
for (const value of ['abcdefgh', 'abcdefghijklmno', 'a'.repeat(65), 'request key spaces']) await assert.rejects(service.grantConsent(terms, value));
|
||||
assert.equal(calls.length, 0);
|
||||
await service.grantConsent(terms, 'abcdefghijklmnop');
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
await test('a response from the previous session cannot confirm consent', async () => {
|
||||
reply = () => { session = json({ token: 'synthetic-token-b', userId: '1' }); return consent; };
|
||||
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
});
|
||||
await test('receipt must match seen hash, scope and version', async () => {
|
||||
for (const patch of [{ agreementVersion: 'old' }, { contentHash: 'b'.repeat(64) }, { scope: 'data' }, { agreementCode: 'privacy' }, { revokedAt: '2026-10-12T01:00:00Z' }]) {
|
||||
reply = () => ({ ...consent, ...patch });
|
||||
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
|
||||
}
|
||||
});
|
||||
await test('a revoked optional grant replay is a valid receipt of the original command', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
|
||||
reply = () => ({ ...consent, scope: 'documents', agreementCode: 'document-use', revokedAt: '2026-10-12T01:00:00Z', canRevoke: false });
|
||||
const result = await service.grantConsent(agreement, 'original_request_123');
|
||||
assert.equal(result.revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.equal(result.canRevoke, false);
|
||||
});
|
||||
await test('account confirmations cannot be revoked even with an incorrect permission flag', async () => {
|
||||
calls = [];
|
||||
await assert.rejects(service.revokeConsent({ ...consent, canRevoke: true }, 'revoke_request_123'));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
await test('optional revocation preserves the original receipt and only sends a request key', async () => {
|
||||
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: '' };
|
||||
calls = []; reply = () => ({ ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' });
|
||||
assert.equal((await service.revokeConsent(item, 'revoke_request_123')).revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.deepEqual(calls, [{ url: '/consents/9/revoke', method: 'POST', payload: { requestId: 'revoke_request_123' } }]);
|
||||
});
|
||||
await test('list validates scope and pagination before querying', async () => {
|
||||
calls = []; reply = () => ({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false });
|
||||
const result = await service.fetchConsents('account-terms', 1);
|
||||
assert.equal(result.items.length, 1);
|
||||
assert.deepEqual(calls[0], { url: '/consents', method: 'GET', payload: { scope: 'account-terms', page: '1', pageSize: '20' } });
|
||||
for (const args of [['unknown', 1], ['', 0], ['', 1.5], ['', 1, 51]]) await assert.rejects(service.fetchConsents(...args));
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
await test('wrong-page and wrong-scope list responses cannot replace user records', async () => {
|
||||
for (const patch of [{ page: 2 }, { pageSize: 10 }, { total: -1 }, { hasMore: null }, { items: [{ ...consent, scope: 'data', agreementCode: 'data-authorization' }] }]) {
|
||||
reply = () => ({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false, ...patch });
|
||||
await assert.rejects(service.fetchConsents('account-terms', 1));
|
||||
}
|
||||
});
|
||||
await test('failed post-login consent retains authentication and retries the same command without logging in again', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
let fail = true;
|
||||
reply = (url, method, payload) => {
|
||||
if (url === '/agreements/terms') return terms;
|
||||
if (url === '/agreements/privacy') return { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
|
||||
if (url === '/auth/password-login') return { token: 'synthetic-login-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
|
||||
if (url === '/consents') {
|
||||
if (fail) { fail = false; throw new Error('synthetic lost receipt'); }
|
||||
return { ...consent, agreementCode: payload.agreementCode, scope: payload.scope, agreementVersion: payload.agreementVersion };
|
||||
}
|
||||
throw Error('Unexpected login request ' + url);
|
||||
};
|
||||
const page = loginPage();
|
||||
if (pageShow) await pageShow();
|
||||
if (page.loadAgreements) await page.loadAgreements();
|
||||
page.phone.value = '13800000000'; page.password.value = 'synthetic-password';
|
||||
if (page.toggleAgreement) page.toggleAgreement(); else page.agreed.value = true;
|
||||
await page.login();
|
||||
assert.equal(session.getString('token'), 'synthetic-login-token');
|
||||
assert.equal(redirects.length, 0, 'login must wait for consent receipts');
|
||||
assert.ok(page.errorMessage.value.length > 0, 'failed consent remains visible for manual retry');
|
||||
await page.login();
|
||||
const authCalls = calls.filter(call => call.url === '/auth/password-login');
|
||||
const consentCalls = calls.filter(call => call.url === '/consents');
|
||||
assert.equal(authCalls.length, 1, 'manual retry must not authenticate again');
|
||||
assert.equal(consentCalls.length, 3, 'one failed grant, its retry and privacy confirmation');
|
||||
assert.equal(consentCalls[0].payload.requestId, consentCalls[1].payload.requestId, 'unknown result must use the original key');
|
||||
assert.notEqual(consentCalls[1].payload.requestId, consentCalls[2].payload.requestId);
|
||||
assert.deepEqual(redirects, ['/pages/home/home']);
|
||||
});
|
||||
await test('unpublished agreements allow existing password login without manufacturing consent records', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
reply = url => {
|
||||
if (url.startsWith('/agreements/')) throw new ApiError('not published', 404, 'AGREEMENT_NOT_AVAILABLE');
|
||||
if (url === '/auth/password-login') return { token: 'synthetic-existing-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
|
||||
throw Error('No consent write is allowed before publication: ' + url);
|
||||
};
|
||||
const page = loginPage(); await pageShow();
|
||||
page.phone.value = '13800000000'; page.password.value = 'synthetic-password';
|
||||
await page.login();
|
||||
assert.equal(calls.filter(call => call.url === '/auth/password-login').length, 1);
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, 0);
|
||||
assert.deepEqual(redirects, ['/pages/home/home']);
|
||||
});
|
||||
await test('network failure loading agreements is never treated as not published', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
reply = () => { throw new Error('synthetic network failure'); };
|
||||
const page = loginPage(); await pageShow();
|
||||
page.phone.value = '13800000000'; page.password.value = 'synthetic-password'; page.agreed.value = true;
|
||||
await page.login();
|
||||
assert.equal(calls.filter(call => call.url.startsWith('/auth/')).length, 0);
|
||||
assert.equal(redirects.length, 0);
|
||||
assert.ok(page.errorMessage.value.length > 0);
|
||||
});
|
||||
await test('an account change while confirmation is pending never grants the previous choice to the new account', async () => {
|
||||
session = null; calls = []; redirects.length = 0;
|
||||
reply = (url, method, payload) => {
|
||||
if (url === '/agreements/terms') return terms;
|
||||
if (url === '/agreements/privacy') return { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
|
||||
if (url === '/auth/password-login') return { token: 'synthetic-login-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
|
||||
if (url === '/consents') throw new Error('synthetic lost receipt');
|
||||
throw Error('Unexpected account-change request ' + url);
|
||||
};
|
||||
const page = loginPage(); await pageShow(); page.phone.value = '13800000000'; page.password.value = 'synthetic-password'; page.toggleAgreement();
|
||||
await page.login();
|
||||
const writes = calls.filter(call => call.url === '/consents').length;
|
||||
session = json({ token: 'synthetic-other-token', userId: '2' });
|
||||
await page.login();
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, writes);
|
||||
assert.equal(page.agreed.value, false, 'old account checkbox must be cleared');
|
||||
assert.equal(redirects.length, 0);
|
||||
});
|
||||
await test('late public agreement responses cannot replace a newer loaded version', async () => {
|
||||
session = null; calls = [];
|
||||
let release;
|
||||
reply = () => new Promise(resolve => { release = resolve; });
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'reload, agreement, available, loading, error');
|
||||
pageLoad({ code: 'terms' }); pageShow(); await flush();
|
||||
assert.equal(typeof release, 'function');
|
||||
reply = () => ({ ...terms, revisionId: '3', version: '2026.10-v2', content: '新合成正文' });
|
||||
await page.reload();
|
||||
release(terms); await flush();
|
||||
assert.equal(page.agreement.value.version, '2026.10-v2');
|
||||
assert.equal(page.agreement.value.content, '新合成正文');
|
||||
assert.equal(page.available.value, true);
|
||||
});
|
||||
await test('an agreement response arriving after leaving cannot fill the hidden page', async () => {
|
||||
session = null;
|
||||
let release; reply = () => new Promise(resolve => { release = resolve; });
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'agreement, available, error');
|
||||
pageLoad({ code: 'terms' }); pageShow(); await flush(); pageHide(); release(terms); await flush();
|
||||
assert.equal(page.available.value, false);
|
||||
assert.equal(page.agreement.value.content, '');
|
||||
assert.equal(page.error.value, '');
|
||||
});
|
||||
await test('optional revocation retries an unknown receipt with the original request key', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
|
||||
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: null };
|
||||
let failed = false;
|
||||
reply = (url, method) => {
|
||||
if (url === '/consents' && method === 'GET') return { items: [item], page: 1, pageSize: 20, total: 1, hasMore: false };
|
||||
if (url === '/consents/9/revoke') {
|
||||
if (!failed) { failed = true; throw Error('synthetic lost revoke receipt'); }
|
||||
return { ...item, revokedAt: '2026-10-12T01:00:00Z', canRevoke: false };
|
||||
}
|
||||
throw Error('Unexpected revocation request ' + url);
|
||||
};
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, pending, actionError, confirmRevoke, retryRevoke');
|
||||
pageShow(); await flush(); assert.equal(page.records.value.length, 1);
|
||||
page.confirmRevoke(page.records.value[0]); await flush();
|
||||
assert.ok(page.pending.value); assert.ok(page.actionError.value.length > 0);
|
||||
await page.retryRevoke();
|
||||
const writes = calls.filter(call => call.url === '/consents/9/revoke');
|
||||
assert.equal(writes.length, 2);
|
||||
assert.equal(writes[0].payload.requestId, writes[1].payload.requestId);
|
||||
assert.equal(page.records.value[0].revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.equal(page.pending.value, null);
|
||||
});
|
||||
await test('late consent records from a former account never enter the new account view', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); redirects.length = 0;
|
||||
let release; reply = () => new Promise(resolve => { release = resolve; });
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, error');
|
||||
pageShow(); await flush(); session = json({ token: 'synthetic-token-b', userId: '2' });
|
||||
release({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false }); await flush();
|
||||
assert.equal(page.records.value.length, 0); assert.equal(page.error.value, '');
|
||||
assert.deepEqual(redirects, ['/packages/account/profile/profile']);
|
||||
});
|
||||
await test('pagination never renders duplicate consent ids after records shift between requests', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
const first = Array.from({ length: 20 }, (_, index) => ({ ...consent, consentId: String(index + 1) }));
|
||||
reply = (url, method, payload) => payload.page === '1'
|
||||
? { items: first, page: 1, pageSize: 20, total: 21, hasMore: true }
|
||||
: { items: [{ ...consent, consentId: '20' }, { ...consent, consentId: '21' }], page: 2, pageSize: 20, total: 21, hasMore: false };
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, loadMore');
|
||||
pageShow(); await flush(); page.loadMore(); await flush();
|
||||
assert.equal(page.records.value.length, 21);
|
||||
assert.equal(new Set(page.records.value.map(item => item.consentId)).size, 21);
|
||||
});
|
||||
await test('a list started before revocation cannot restore the revoked consent after its receipt', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' });
|
||||
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: null };
|
||||
let listCount = 0, release, releaseRevoke, revoked = false;
|
||||
reply = (url, method) => {
|
||||
if (url === '/consents' && method === 'GET') {
|
||||
listCount++;
|
||||
const currentItem = revoked ? { ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' } : { ...item };
|
||||
const result = { items: [currentItem], page: 1, pageSize: 20, total: 1, hasMore: false };
|
||||
if (listCount === 2) return new Promise(resolve => { release = () => resolve(result); });
|
||||
return result;
|
||||
}
|
||||
if (url === '/consents/9/revoke') return new Promise(resolve => { releaseRevoke = () => { revoked = true; resolve({ ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' }); }; });
|
||||
throw Error('Unexpected revoke/list race request ' + url);
|
||||
};
|
||||
const page = componentPage('packages/account/consents/consents.uvue', 'records, loading, reload, confirmRevoke');
|
||||
pageShow(); await flush(); page.confirmRevoke(page.records.value[0]); await flush(); page.reload(); await flush();
|
||||
assert.equal(page.loading.value, true);
|
||||
releaseRevoke(); await flush(); release(); await flush();
|
||||
const visible = page.records.value.find(row => row.consentId === '9');
|
||||
assert.ok(visible, 'receipt should retain the record while invalidating its in-flight refresh');
|
||||
assert.equal(visible.revokedAt, '2026-10-12T01:00:00Z');
|
||||
assert.equal(visible.canRevoke, false);
|
||||
assert.equal(page.loading.value, false);
|
||||
});
|
||||
await test('returning to an optional page settles a revoked original grant and waits for a new explicit click', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
|
||||
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
|
||||
const original = { ...consent, agreementCode: 'document-use', scope: 'documents', revokedAt: null, canRevoke: true };
|
||||
let originalKey = '', revoked = false;
|
||||
reply = (url, method, payload) => {
|
||||
if (url === '/agreements/document-use') return agreement;
|
||||
if (url === '/consents' && method === 'POST') {
|
||||
if (!originalKey) { originalKey = payload.requestId; throw Error('synthetic accepted but lost receipt'); }
|
||||
if (payload.requestId === originalKey) return { ...original, revokedAt: revoked ? '2026-10-12T01:00:00Z' : null, canRevoke: !revoked };
|
||||
return { ...original, consentId: '10' };
|
||||
}
|
||||
throw Error('Unexpected optional replay request ' + url);
|
||||
};
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'confirmConsent, pendingId, granted, actionMessage');
|
||||
pageLoad({ code: 'document-use' }); pageShow(); await flush(); await page.confirmConsent();
|
||||
assert.ok(page.pendingId.value);
|
||||
pageHide(); revoked = true; pageShow(); await flush();
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, 1, 'return must not automatically submit another grant');
|
||||
await page.confirmConsent();
|
||||
assert.equal(page.pendingId.value, '', 'revoked replay settles the pending command');
|
||||
assert.equal(page.granted.value, false, 'revoked original receipt must not show currently granted');
|
||||
assert.match(page.actionMessage.value, /此前同意已撤销/);
|
||||
await page.confirmConsent();
|
||||
const writes = calls.filter(call => call.url === '/consents');
|
||||
assert.equal(writes.length, 3);
|
||||
assert.equal(writes[0].payload.requestId, writes[1].payload.requestId);
|
||||
assert.notEqual(writes[1].payload.requestId, writes[2].payload.requestId);
|
||||
assert.equal(page.granted.value, true);
|
||||
});
|
||||
await test('returning after revoking a successful optional grant restores an explicit fresh consent action', async () => {
|
||||
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
|
||||
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
|
||||
reply = (url, method) => {
|
||||
if (url === '/agreements/document-use') return agreement;
|
||||
if (url === '/consents' && method === 'POST') return { ...consent, agreementCode: 'document-use', scope: 'documents', revokedAt: null, canRevoke: true };
|
||||
throw Error('Unexpected successful-grant return request ' + url);
|
||||
};
|
||||
const page = componentPage('pages/agreement/agreement.uvue', 'confirmConsent, pendingId, granted');
|
||||
pageLoad({ code: 'document-use' }); pageShow(); await flush(); await page.confirmConsent();
|
||||
assert.equal(page.granted.value, true);
|
||||
assert.equal(page.pendingId.value, '');
|
||||
pageHide(); pageShow(); await flush();
|
||||
assert.equal(page.granted.value, false, 'return must expose an explicit consent action instead of retaining old success state');
|
||||
assert.equal(calls.filter(call => call.url === '/consents').length, 1, 'return must not automatically regrant');
|
||||
await page.confirmConsent();
|
||||
const writes = calls.filter(call => call.url === '/consents');
|
||||
assert.equal(writes.length, 2);
|
||||
assert.notEqual(writes[0].payload.requestId, writes[1].payload.requestId);
|
||||
});
|
||||
console.log(checks + ' checks passed; no network calls.');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user