159 lines
11 KiB
JavaScript
159 lines
11 KiB
JavaScript
'use strict';
|
|
const { test } = require('node:test'), assert = require('node:assert/strict'), crypto = require('node:crypto');
|
|
const auth = require('../../h5/lib/taobao-auth');
|
|
test('gateway signature covers raw JSON, identity, extra query fields and algorithm; duplicates fail closed', () => {
|
|
const env = { TAOBAO_APP_KEY: 'app', TAOBAO_APP_SECRET: 'secret' }, body = '{"consent":true,"challenge":"nonce"}';
|
|
const params = { app_key: 'app', open_id: 'open-account', sign_method: 'md5', env: 'online' };
|
|
const expected = crypto.createHash('md5').update('secretapp_keyappenvonlineopen_idopen-accountsign_methodmd5' + body + 'secret').digest('hex').toUpperCase();
|
|
assert.equal(auth.signature(params, body, env.TAOBAO_APP_SECRET), expected);
|
|
const url = '/api/taobao/session?' + new URLSearchParams({ ...params, sign: expected });
|
|
assert(auth.verify(url, body, env));
|
|
assert.equal(auth.verify(url, body + ' ', env), null);
|
|
assert.equal(auth.verify(url.replace('open-account', 'forged-account'), body, env), null);
|
|
assert.equal(auth.verify(url + '&open_id=open-account', body, env), null);
|
|
assert.equal(auth.verify(url, body, {}), null);
|
|
});
|
|
|
|
test('guest gateway verification permits missing open_id but still requires app identity and an intact signature', () => {
|
|
const env = { TAOBAO_APP_KEY: 'fixture-app', TAOBAO_APP_SECRET: 'fixture-secret' };
|
|
const body = '{"consent":true,"challenge":"fixture-challenge"}';
|
|
const params = { app_key: env.TAOBAO_APP_KEY, env: 'test', request_id: 'gateway-request' };
|
|
const signed = values => '/api/taobao/guest-session?' + new URLSearchParams({ ...values, sign: auth.signature(values, body, env.TAOBAO_APP_SECRET) });
|
|
const url = signed(params);
|
|
assert.equal(auth.verifyGateway(url, body, env).appKey, env.TAOBAO_APP_KEY);
|
|
assert.equal(auth.verify(url, body, env), null, 'normal login still requires a signed open_id');
|
|
assert.equal(auth.verifyGateway('/api/taobao/guest-session', body, env), null);
|
|
assert.equal(auth.verifyGateway(url, body + ' ', env), null);
|
|
assert.equal(auth.verifyGateway(signed({ ...params, app_key: 'other-app' }), body, env), null);
|
|
assert.equal(auth.verifyGateway(url + '&env=test', body, env), null);
|
|
assert.equal(auth.verifyGateway(signed({ ...params, sign_method: 'none' }), body, env), null);
|
|
});
|
|
test('photo chunks are ordered, bounded, deduplicated, owner-bound, expiring and consumed once', async () => {
|
|
let now = 0;
|
|
const req = { headers: {}, owner: 'alice' }, other = { headers: {}, owner: 'bob' };
|
|
const runtime = require('../../h5/lib/taobao-session')({ now: () => now, session: request => ({ id: request.owner, kind: 'taobao' }) });
|
|
const piece = { view: 'front', upload_id: 'test-upload-id', index: 0, total: 2, data: 'AAAA' };
|
|
assert.equal(runtime.upload(req, { ...piece, index: 1 }).status, 409);
|
|
assert.equal(runtime.upload(req, piece).complete, false);
|
|
assert.equal(runtime.upload(req, piece).complete, false, 'repeated identical chunk is idempotent');
|
|
assert.equal(runtime.upload(req, { ...piece, data: 'BBBB' }).status, 409);
|
|
assert.equal(runtime.upload(req, { ...piece, data: 'A'.repeat(65537) }).status, 400);
|
|
assert.equal(runtime.upload(req, { ...piece, index: 1 }).complete, true);
|
|
assert.equal(runtime.takePhotos(other, { front: piece.upload_id }), null);
|
|
for (const view of ['left', 'right']) assert.equal(runtime.upload(req, { ...piece, view, total: 1 }).status, 200);
|
|
const refs = { front: piece.upload_id, left: piece.upload_id, right: piece.upload_id };
|
|
assert(runtime.takePhotos(req, refs)); assert.equal(runtime.takePhotos(req, refs), null);
|
|
assert.equal(runtime.upload(req, piece).status, 409);
|
|
let complete;
|
|
const job = runtime.start(req, () => new Promise(resolve => { complete = resolve; }), true);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(runtime.result(other, job), null); assert.equal(runtime.result(req, job).status, 'running');
|
|
complete({ ok: true }); await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(runtime.result(req, job).status, 'complete');
|
|
assert.equal(runtime.upload(req, piece).status, 200);
|
|
now = 5 * 60000 + 1; assert.equal(runtime.takePhotos(req, refs), null);
|
|
const challenge = runtime.challenge('local'); assert(runtime.consume(challenge)); assert(!runtime.consume(challenge));
|
|
});
|
|
|
|
test('analysis attempts are session scoped, deduplicated, content bound and never restarted after result expiry', async () => {
|
|
let now = 0, charged = 0;
|
|
const runtime = require('../../h5/lib/taobao-session')({ now: () => now, session: () => ({ id: 'same-account', kind: 'taobao' }) });
|
|
const alice = { headers: { authorization: 'Bearer fixture-session-a' } }, bob = { headers: { authorization: 'Bearer fixture-session-b' } };
|
|
const body = { photo_refs: { front: 'front-upload', left: 'left-upload', right: 'right-upload' }, profile: { real_age: 30, gender: '女' } };
|
|
const requestId = 'analysis-attempt-1';
|
|
for (const req of [alice, bob]) {
|
|
for (const view of ['front', 'left', 'right']) assert.equal(runtime.upload(req, { view, upload_id: body.photo_refs[view], index: 0, total: 1, data: 'AAAA' }).status, 200);
|
|
}
|
|
assert(runtime.takePhotos(alice, body.photo_refs));
|
|
assert(runtime.takePhotos(bob, body.photo_refs), 'same account on two devices cannot consume each other\'s uploads');
|
|
const attempt = runtime.submission(alice, requestId, body);
|
|
assert.equal(attempt.status, 200);
|
|
const job = runtime.start(alice, async () => { charged++; return { ok: true }; }, true, attempt);
|
|
assert.equal(runtime.result(bob, job), null, 'same-account Bearers cannot read each other\'s transient jobs');
|
|
assert.deepEqual(runtime.submission(alice, requestId, body), { status: 202, job });
|
|
const reordered = { profile: { gender: '女', real_age: 30 }, photo_refs: { right: 'right-upload', front: 'front-upload', left: 'left-upload' } };
|
|
assert.equal(runtime.submission(alice, requestId, reordered).job, job, 'JSON property order does not change the request identity');
|
|
assert.equal(runtime.submission(alice, requestId, { ...body, profile: { real_age: 31 } }).status, 409);
|
|
assert.equal(runtime.submission(alice, '', body).status, 400);
|
|
assert.equal(runtime.submission(alice, undefined, body).status, 200, 'older callers remain supported');
|
|
const second = runtime.submission(bob, requestId, body);
|
|
assert.equal(second.status, 200, 'an id in another Bearer session never exposes a prior job');
|
|
const otherJob = runtime.start(bob, async () => { charged++; return { ok: true }; }, true, second);
|
|
assert.notEqual(otherJob, job);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(charged, 2);
|
|
assert.equal(runtime.submission(alice, requestId, body).job, job, 'completed attempts still return the first job');
|
|
now = 5 * 60000 + 1;
|
|
assert.equal(runtime.result(alice, job), null);
|
|
assert.equal(runtime.submission(alice, requestId, body).status, 410, 'expired results retain a non-recharge tombstone');
|
|
assert.equal(charged, 2);
|
|
runtime.revoke(bob);
|
|
assert.equal(runtime.result(bob, otherJob), null);
|
|
});
|
|
|
|
test('a full analysis queue preserves uploaded photos and the same attempt can retry exactly once after capacity returns', async () => {
|
|
let charged = 0;
|
|
const runtime = require('../../h5/lib/taobao-session')({ session: () => ({ id: 'account', kind: 'taobao' }) });
|
|
const client = { headers: { authorization: 'Bearer upload-session' } };
|
|
const busy = { headers: { authorization: 'Bearer busy-session' } };
|
|
for (let i = 0; i < 1000; i++) assert(runtime.start(busy, () => new Promise(() => {})));
|
|
const refs = {};
|
|
for (const view of ['front', 'left', 'right']) {
|
|
refs[view] = view + '-capacity-upload';
|
|
assert.equal(runtime.upload(client, { view, upload_id: refs[view], index: 0, total: 1, data: 'AAAA' }).status, 200);
|
|
}
|
|
const body = { request_id: 'capacity-retry-attempt', photo_refs: refs, profile: { real_age: 30 } };
|
|
const run = async photos => { charged++; assert.equal(photos.front_image, 'AAAA'); return { ok: true }; };
|
|
assert.equal(runtime.beginAnalysis(client, body, run).status, 429);
|
|
assert.equal(charged, 0);
|
|
runtime.revoke(busy);
|
|
const accepted = runtime.beginAnalysis(client, body, run);
|
|
assert.equal(accepted.status, 202, 'retry succeeds with the original uploaded refs');
|
|
assert.deepEqual(runtime.beginAnalysis(client, body, run), accepted, 'duplicate delivery returns the reserved job');
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(charged, 1);
|
|
assert.equal(runtime.result(client, accepted.job).payload.ok, true);
|
|
assert.deepEqual(runtime.beginAnalysis(client, body, run), accepted, 'completion also retains idempotency');
|
|
assert.equal(charged, 1);
|
|
});
|
|
|
|
test('login challenges expire, are consumed once and are rate limited', () => {
|
|
let now = 0;
|
|
const runtime = require('../../h5/lib/taobao-session')({ now: () => now, session: () => null });
|
|
const expired = runtime.challenge('address-a');
|
|
now = 60001;
|
|
assert.equal(runtime.consume(expired), false);
|
|
const once = runtime.challenge('address-a');
|
|
assert.equal(runtime.consume(once), true);
|
|
assert.equal(runtime.consume(once), false);
|
|
for (let i = 0; i < 29; i++) assert(runtime.challenge('address-a'));
|
|
assert.equal(runtime.challenge('address-a'), null);
|
|
now += 60001;
|
|
assert(runtime.challenge('address-a'));
|
|
});
|
|
|
|
test('miniapp guest Bearers expire, revoke and disappear on reload without entering the account database', t => {
|
|
const fs = require('node:fs'), path = require('node:path');
|
|
const tempRoot = path.resolve(__dirname, '../../temp');
|
|
const dir = fs.mkdtempSync(path.join(tempRoot, 'guest-account-test-'));
|
|
t.after(() => { assert(dir.startsWith(tempRoot + path.sep)); fs.rmSync(dir, { recursive: true, force: true }); });
|
|
let now = Date.now();
|
|
t.mock.method(Date, 'now', () => now);
|
|
const createAccounts = require('../../h5/lib/accounts'), accounts = createAccounts(dir, {});
|
|
const guest = accounts.loginTaobaoGuest();
|
|
const req = { headers: { authorization: 'Bearer ' + guest.token } };
|
|
assert.equal(accounts.taobaoSession(req).kind, 'taobao_guest');
|
|
assert.equal(fs.existsSync(path.join(dir, 'identities.json')), false);
|
|
assert.throws(() => accounts.storeReport(guest.user.id, { id: 'Rguest', vm: {} }), /Invalid report owner/);
|
|
assert.equal(createAccounts(dir, {}).taobaoSession(req), null, 'new account store never reloads guest credentials');
|
|
now += 8 * 3600000 + 1;
|
|
assert.equal(accounts.taobaoSession(req), null);
|
|
const saved = accounts.loginTaobao('fixture-provider-key');
|
|
const dbBeforeGuest = fs.readFileSync(path.join(dir, 'identities.json'), 'utf8');
|
|
const visit = accounts.loginTaobaoGuest(), visitReq = { headers: { authorization: 'Bearer ' + visit.token } };
|
|
accounts.taobaoLogout(visitReq);
|
|
assert.equal(accounts.taobaoSession(visitReq), null);
|
|
assert.equal(accounts.taobaoSession({ headers: { authorization: 'Bearer ' + saved.token } }).kind, 'taobao');
|
|
assert.equal(fs.readFileSync(path.join(dir, 'identities.json'), 'utf8'), dbBeforeGuest, 'guest creation and logout never rewrite permanent identities');
|
|
});
|