Skip to content

Commit f75f0aa

Browse files
authored
feat: add new subStatus command (#248)
1 parent 0594717 commit f75f0aa

File tree

8 files changed

+166
-1
lines changed

8 files changed

+166
-1
lines changed

.changeset/fresh-foxes-turn.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pwned': minor
3+
---
4+
5+
Add new `subStatus` command to get the current subscription status of your HIBP API key. See https://haveibeenpwned.com/API/v3#SubscriptionStatus for more information.

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Commands:
5555
pwned pw <password> securely check a password for public exposure
5656
pwned search <account|email> search breaches and pastes for an account (username or email
5757
address)
58+
pwned subStatus get your subscription status
5859
5960
Options:
6061
-h, --help Show help [boolean]

bin/pwned.js

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as dc from '../lib/commands/dc.js';
1414
import * as pa from '../lib/commands/pa.js';
1515
import * as pw from '../lib/commands/pw.js';
1616
import * as search from '../lib/commands/search.js';
17+
import * as subStatus from '../lib/commands/sub-status.js';
1718
/* eslint-enable */
1819

1920
sourceMapSupport.install();
@@ -32,6 +33,7 @@ yargs(hideBin(process.argv))
3233
.command(pa)
3334
.command(pw)
3435
.command(search)
36+
.command(subStatus)
3537
.demandCommand()
3638
.recommendCommands()
3739
.strict()

docs/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Commands:
5555
pwned pw <password> securely check a password for public exposure
5656
pwned search <account|email> search breaches and pastes for an account (username or email
5757
address)
58+
pwned subStatus get your subscription status
5859
5960
Options:
6061
-h, --help Show help [boolean]
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { vi, type SpyInstance } from 'vitest';
2+
import { http } from 'msw';
3+
import { server } from '../../../test/server.js';
4+
import { spinnerFns, loggerFns, ERROR_MSG } from '../../../test/fixtures.js';
5+
import { logger as mockLogger, type Logger } from '../../utils/logger.js';
6+
import { spinner as mockSpinner } from '../../utils/spinner.js';
7+
import { handler as subStatus } from '../sub-status.js';
8+
9+
vi.mock('../../utils/logger');
10+
vi.mock('../../utils/spinner');
11+
12+
const logger = mockLogger as Logger & Record<string, SpyInstance>;
13+
const spinner = mockSpinner as typeof mockSpinner & Record<string, SpyInstance>;
14+
15+
describe('command: subStatus', () => {
16+
describe('normal output (default)', () => {
17+
it('calls spinner.start', async () => {
18+
expect(spinner.start).toHaveBeenCalledTimes(0);
19+
await subStatus({ raw: false });
20+
expect(spinner.start).toHaveBeenCalledTimes(1);
21+
});
22+
23+
it('with data: calls spinner.stop and logger.log', async () => {
24+
expect(spinner.stop).toHaveBeenCalledTimes(0);
25+
expect(logger.log).toHaveBeenCalledTimes(0);
26+
await subStatus({ raw: false });
27+
expect(spinner.stop).toHaveBeenCalledTimes(1);
28+
expect(logger.log).toHaveBeenCalledTimes(1);
29+
});
30+
31+
it('on error: only calls spinner.fail', async () => {
32+
server.use(
33+
http.get('*', () => {
34+
throw new Error(ERROR_MSG);
35+
}),
36+
);
37+
38+
expect(spinner.fail).toHaveBeenCalledTimes(0);
39+
loggerFns.forEach((fn) => expect(logger[fn]).toHaveBeenCalledTimes(0));
40+
await subStatus({ raw: false });
41+
expect(spinner.fail).toHaveBeenCalledTimes(1);
42+
loggerFns.forEach((fn) => expect(logger[fn]).toHaveBeenCalledTimes(0));
43+
});
44+
});
45+
46+
describe('raw mode', () => {
47+
it('does not call spinner.start', async () => {
48+
expect(spinner.start).toHaveBeenCalledTimes(0);
49+
await subStatus({ raw: true });
50+
expect(spinner.start).toHaveBeenCalledTimes(0);
51+
});
52+
53+
it('with data: only calls logger.log', async () => {
54+
spinnerFns.forEach((fn) => expect(spinner[fn]).toHaveBeenCalledTimes(0));
55+
expect(logger.log).toHaveBeenCalledTimes(0);
56+
await subStatus({ raw: true });
57+
spinnerFns.forEach((fn) => expect(spinner[fn]).toHaveBeenCalledTimes(0));
58+
expect(logger.log).toHaveBeenCalledTimes(1);
59+
});
60+
61+
it('on error: only calls logger.error', async () => {
62+
server.use(
63+
http.get('*', () => {
64+
throw new Error(ERROR_MSG);
65+
}),
66+
);
67+
68+
spinnerFns.forEach((fn) => expect(spinner[fn]).toHaveBeenCalledTimes(0));
69+
expect(logger.error).toHaveBeenCalledTimes(0);
70+
await subStatus({ raw: true });
71+
spinnerFns.forEach((fn) => expect(spinner[fn]).toHaveBeenCalledTimes(0));
72+
expect(logger.error).toHaveBeenCalledTimes(1);
73+
});
74+
});
75+
});

src/commands/sub-status.ts

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Argv } from 'yargs';
2+
import { subscriptionStatus } from 'hibp';
3+
import prettyjson from 'prettyjson';
4+
import { config } from '../config.js';
5+
import { logger } from '../utils/logger.js';
6+
import { spinner } from '../utils/spinner.js';
7+
import { userAgent } from '../utils/user-agent.js';
8+
9+
export const command = 'subStatus';
10+
export const describe = 'get your subscription status';
11+
12+
interface SubStatusArgvOptions {
13+
r?: boolean;
14+
}
15+
16+
interface SubStatusHandlerOptions {
17+
raw?: boolean;
18+
}
19+
20+
/* c8 ignore start */
21+
export function builder(
22+
yargs: Argv<SubStatusArgvOptions>,
23+
): Argv<SubStatusHandlerOptions> {
24+
return yargs
25+
.option('r', {
26+
describe: 'output the raw JSON data',
27+
type: 'boolean',
28+
default: false,
29+
})
30+
.alias('r', 'raw')
31+
.group(['r'], 'Command Options:')
32+
.group(['h', 'v'], 'Global Options:');
33+
}
34+
/* c8 ignore stop */
35+
36+
/**
37+
* Fetches and outputs your subscription status (of your API key).
38+
*
39+
* @param {object} argv the parsed argv object
40+
* @param {boolean} [argv.raw] output the raw JSON data (default: false)
41+
* @returns {Promise<void>} the resulting Promise where output is rendered
42+
*/
43+
export async function handler({ raw }: SubStatusHandlerOptions): Promise<void> {
44+
if (!raw) {
45+
spinner.start();
46+
}
47+
48+
try {
49+
const subStatusData = await subscriptionStatus({
50+
apiKey: config.get('apiKey'),
51+
userAgent,
52+
});
53+
if (raw) {
54+
logger.log(JSON.stringify(subStatusData));
55+
} else {
56+
spinner.stop();
57+
logger.log(prettyjson.render(subStatusData));
58+
}
59+
} catch (err: unknown) {
60+
/* c8 ignore else */
61+
if (err instanceof Error) {
62+
if (!raw) {
63+
spinner.fail(err.message);
64+
} else {
65+
logger.error(err.message);
66+
}
67+
}
68+
}
69+
}

test/fixtures.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { stripIndents } from 'common-tags';
2-
import type { Breach, Paste } from 'hibp';
2+
import type { Breach, Paste, SubscriptionStatus } from 'hibp';
33

44
export const spinnerFns = ['start', 'stop', 'succeed', 'warn', 'fail'];
55
export const loggerFns = ['info', 'log', 'warn', 'error'];
@@ -40,6 +40,14 @@ export const PASSWORD_HASHES = stripIndents`
4040
01330C689E5D64F660D6947A93AD634EF8F:1
4141
`;
4242

43+
export const SUBSCRIPTION_STATUS: SubscriptionStatus = {
44+
SubscriptionName: 'Pwned 42',
45+
Description: 'A mock subscrpition',
46+
SubscribedUntil: '2023-12-31T01:23:45',
47+
Rpm: 69,
48+
DomainSearchMaxBreachedAccounts: 0,
49+
};
50+
4351
export const EMAIL = '[email protected]';
4452
export const EMPTY_ARRAY = [];
4553
export const ERROR = 'foo';

test/handlers.ts

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DATA_CLASSES,
77
PASTES,
88
PASSWORD_HASHES,
9+
SUBSCRIPTION_STATUS,
910
EMPTY_ARRAY,
1011
} from './fixtures.js';
1112

@@ -45,4 +46,7 @@ export const handlers = [
4546
http.get('*/range/:suffix', () => {
4647
return new Response(PASSWORD_HASHES);
4748
}),
49+
http.get('*/subscription/status', () => {
50+
return new Response(JSON.stringify(SUBSCRIPTION_STATUS));
51+
}),
4852
];

0 commit comments

Comments
 (0)