miiverse-api/test/test.ts
2025-02-01 20:36:29 +00:00

296 lines
10 KiB
TypeScript

import crypto from 'node:crypto';
import newman from 'newman';
import qs from 'qs';
import axios from 'axios';
import { create as parseXML } from 'xmlbuilder2';
import { table } from 'table';
import ora from 'ora';
import dotenv from 'dotenv';
import colors from 'colors';
import communitiesCollection from '../postman/collections/Communities.json';
import peopleCollection from '../postman/collections/People.json';
import type { Collection, CollectionDefinition } from 'postman-collection';
const PeopleCollection: CollectionDefinition = peopleCollection as CollectionDefinition;
const CommunitiesCollection: CollectionDefinition = communitiesCollection as CollectionDefinition;
dotenv.config();
colors.enable();
interface TestResult {
collection: string;
name: string;
url: string;
query: string;
assertion: string;
error?: string;
}
const USERNAME = process.env.PN_MIIVERSE_API_TESTING_USERNAME?.trim() || '';
const PASSWORD = process.env.PN_MIIVERSE_API_TESTING_PASSWORD?.trim() || '';
const DEVICE_ID = process.env.PN_MIIVERSE_API_TESTING_DEVICE_ID?.trim() || '';
const SERIAL_NUMBER = process.env.PN_MIIVERSE_API_TESTING_SERIAL_NUMBER?.trim() || '';
const CERTIFICATE = process.env.PN_MIIVERSE_API_TESTING_CONSOLE_CERT?.trim() || '';
if (!USERNAME) {
throw new Error('PNID username missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_USERNAME');
}
if (!PASSWORD) {
throw new Error('PNID password missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_PASSWORD');
}
if (!DEVICE_ID) {
throw new Error('Console device ID missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_DEVICE_ID');
}
if (!SERIAL_NUMBER) {
throw new Error('Console serial number missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_SERIAL_NUMBER');
}
if (!CERTIFICATE) {
throw new Error('Console certificate missing. Required for requesting service tokens. Set PN_MIIVERSE_API_TESTING_CONSOLE_CERT');
}
const BASE_URL = 'https://account.pretendo.cc';
const API_URL = `${BASE_URL}/v1/api`;
const MAPPED_IDS_URL = `${API_URL}/admin/mapped_ids`;
const ACCESS_TOKEN_URL = `${API_URL}/oauth20/access_token/generate`;
const SERVICE_TOKEN_URL = `${API_URL}/provider/service_token/@me?client_id=87cd32617f1985439ea608c2746e4610`;
const DEFAULT_HEADERS = {
'X-Nintendo-Client-ID': 'a2efa818a34fa16b8afbc8a74eba3eda',
'X-Nintendo-Client-Secret': 'c91cdb5658bd4954ade78533a339cf9a',
'X-Nintendo-Device-ID': DEVICE_ID,
'X-Nintendo-Serial-Number': SERIAL_NUMBER,
'X-Nintendo-Device-Cert': CERTIFICATE
};
export function nintendoPasswordHash(password: string, pid: number): string {
const pidBuffer = Buffer.alloc(4);
pidBuffer.writeUInt32LE(pid);
const unpacked = Buffer.concat([
pidBuffer,
Buffer.from('\x02\x65\x43\x46'),
Buffer.from(password)
]);
return crypto.createHash('sha256').update(unpacked).digest().toString('hex');
}
async function apiGetRequest(url: string, headers = {}): Promise<Record<string, any>> {
const response = await axios.get(url, {
headers: Object.assign(headers, DEFAULT_HEADERS),
validateStatus: () => true
});
const data: Record<string, any> = parseXML(response.data).end({ format: 'object' });
if (data.errors) {
throw new Error(data.errors.error.message);
}
if (data.error) {
throw new Error(data.error.message);
}
return data;
}
async function apiPostRequest(url: string, body: string): Promise<Record<string, any>> {
const response = await axios.post(url, body, {
headers: DEFAULT_HEADERS,
validateStatus: () => true
});
const data: Record<string, any> = parseXML(response.data).end({ format: 'object' });
if (data.errors) {
throw new Error(data.errors.error.message);
}
if (data.error) {
throw new Error(data.error.message);
}
return data;
}
async function getPID(username: string): Promise<number> {
const response = await apiGetRequest(`${MAPPED_IDS_URL}?input_type=user_id&output_type=pid&input=${username}`);
return Number(response.mapped_ids.mapped_id.out_id);
}
async function getAccessToken(username: string, passwordHash: string): Promise<string> {
const data = qs.stringify({
grant_type: 'password',
user_id: username,
password: passwordHash,
password_type: 'hash'
});
const response = await apiPostRequest(ACCESS_TOKEN_URL, data);
return response.OAuth20.access_token.token;
}
async function getMiiverseServiceToken(accessToken: string): Promise<string> {
const response = await apiGetRequest(SERVICE_TOKEN_URL, {
'X-Nintendo-Title-ID': '0005001010040100',
'Authorization': `Bearer ${accessToken}`
});
return response.service_token.token;
}
function runNewmanTest(collection: string | Collection | CollectionDefinition, variables: Record<string, string>): Promise<TestResult[]> {
return new Promise((resolve, reject) => {
newman.run({
collection: collection,
reporters: ['json'],
envVar: Object.entries(variables).map(entry => ({ key: entry[0], value: entry[1] })),
globals: variables,
globalVar: Object.entries(variables).map(entry => ({ key: entry[0], value: entry[1] }))
}, (error, summary) => {
if (error) {
reject(error);
} else {
resolve(createTestResults(summary));
}
});
});
}
function communitiesRoutesTest(serviceToken: string): Promise<TestResult[]> {
// TODO - Make this more dynamic?
return runNewmanTest(CommunitiesCollection, {
'DOMAIN': 'api.olv.pretendo.cc',
'ServiceToken': serviceToken,
// TODO - Change these names. Should not be game-specific
'PP_Splatoon': 'XHRpdGxlX2lkXDE0MDczNzUxNTM1MjI5NDRcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc',
'PP_MarioVsDK': 'XHRpdGxlX2lkXDE0MDczNzUxNTMzMzcwODhcYWNjZXNzX2tleVw2OTI0NzQ1MTBccGxhdGZvcm1faWRcMVxyZWdpb25faWRcMlxsYW5ndWFnZV9pZFwxXGNvdW50cnlfaWRcNDlcYXJlYV9pZFwwXG5ldHdvcmtfcmVzdHJpY3Rpb25cMFxmcmllbmRfcmVzdHJpY3Rpb25cMFxyYXRpbmdfcmVzdHJpY3Rpb25cMTdccmF0aW5nX29yZ2FuaXphdGlvblwxXHRyYW5zZmVyYWJsZV9pZFw3NjA4MjAyOTE2MDc1ODg0NDI1XHR6X25hbWVcUGFjaWZpYy9NaWR3YXlcdXRjX29mZnNldFwtMzk2MDBc',
'PP_Bad_TID': 'XHRpdGxlX2lkXDEyMzRcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc',
'PP_ACPlaza': 'XHRpdGxlX2lkXDE0MDczNzUxNTMzMjE0NzJcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc',
'PP_Bad Format': 'XHR'
});
}
function peopleRoutesTest(serviceToken: string): Promise<TestResult[]> {
// TODO - Make this more dynamic?
return runNewmanTest(PeopleCollection, {
DOMAIN: 'api.olv.pretendo.cc',
ServiceToken: serviceToken,
// TODO - Change this name. Should not be game-specific
PP_Splatoon: 'XHRpdGxlX2lkXDE0MDczNzUxNTM1MjI5NDRcYWNjZXNzX2tleVwwXHBsYXRmb3JtX2lkXDFccmVnaW9uX2lkXDJcbGFuZ3VhZ2VfaWRcMVxjb3VudHJ5X2lkXDExMFxhcmVhX2lkXDBcbmV0d29ya19yZXN0cmljdGlvblwwXGZyaWVuZF9yZXN0cmljdGlvblwwXHJhdGluZ19yZXN0cmljdGlvblwyMFxyYXRpbmdfb3JnYW5pemF0aW9uXDBcdHJhbnNmZXJhYmxlX2lkXDEyNzU2MTQ0ODg0NDUzODk4NzgyXHR6X25hbWVcQW1lcmljYS9OZXdfWW9ya1x1dGNfb2Zmc2V0XC0xNDQwMFxyZW1hc3Rlcl92ZXJzaW9uXDBc'
});
}
async function main(): Promise<void> {
const tokensSpinner = ora('Acquiring account tokens').start();
const pid = await getPID(USERNAME);
const passwordHash = nintendoPasswordHash(PASSWORD, pid);
const accessToken = await getAccessToken(USERNAME, passwordHash);
const serviceToken = await getMiiverseServiceToken(accessToken);
tokensSpinner.succeed();
const testsSpinner = ora('Running tests').start();
const results: TestResult[] = [
...await communitiesRoutesTest(serviceToken),
...await peopleRoutesTest(serviceToken)
];
const passed = results.filter(result => !result.error);
const failed = results.filter(result => result.error);
if (failed.length !== 0) {
testsSpinner.warn('Some tests have failed! See below for details');
} else {
testsSpinner.succeed('All tests passed!');
}
const testsOverviewData = [
['Tests Ran'.cyan, results.length.toString().cyan],
['Passed'.green, passed.length.toString().green]
];
if (failed.length === 0) {
testsOverviewData.push(['Failed'.red, failed.length.toString().green]);
} else {
testsOverviewData.push(['Failed'.red, failed.length.toString().red]);
}
const config = {
singleLine: true,
border: {
topBody: '─',
topJoin: '┬',
topLeft: '┌',
topRight: '┐',
bottomBody: '─',
bottomJoin: '┴',
bottomLeft: '└',
bottomRight: '┘',
bodyLeft: '│',
bodyRight: '│',
bodyJoin: '│',
joinBody: '─',
joinLeft: '├',
joinRight: '┤',
joinJoin: '┼'
}
};
console.log(table(testsOverviewData, config));
if (failed.length !== 0) {
console.log('Failed tests:\n'.red.underline.italic.bold);
for (const test of failed) {
console.log('Collection:'.bold, test.collection.red.bold);
console.log('Test Name:'.bold, test.name.red.bold);
console.log('URL:'.bold, `${test.url}${test.query ? '?' + test.query : ''}`.red.bold);
console.log('Message:'.bold, test.error?.red.bold);
console.log('\n');
}
}
}
main();
function createTestResults(summary: newman.NewmanRunSummary): TestResult[] {
const results: TestResult[] = [];
for (const execution of summary.run.executions) {
const request = execution.request;
for (const assertion of execution.assertions) {
const result: TestResult = {
collection: summary.collection.name,
name: execution.item.name,
url: `${request.url.protocol}://${request.url.host?.join('.')}/${request.url.path?.join('/')}`,
query: qs.stringify(request.url.query.all().reduce((object: Record<string, string>, item: { disabled?: boolean; key: string | null; value: string | null }) => {
if (!item.disabled && item.key && item.value) {
object[item.key] = item.value;
}
return object;
}, {})),
assertion: assertion.assertion
};
if (assertion.error) {
result.error = `${assertion.error.name}: ${assertion.error.message}`;
}
results.push(result);
}
}
return results;
}