Skip to content

Commit

Permalink
hub: Reject already-used IAP receipts (#3065)
Browse files Browse the repository at this point in the history
This updates POST /api/profile-purchases to reject an attempt to reuse
an IAP receipt that has been referenced in any job_tickets row,
regardless of owner_address.
  • Loading branch information
backspace committed Jul 7, 2022
1 parent aaa33ef commit d7ef333
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 33 deletions.
2 changes: 1 addition & 1 deletion packages/hub/node-tests/routes/job-tickets-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe('POST /api/job-tickets/:id/retry', function () {
expect(getJobPayloads()).to.deep.equal([{ 'a-payload': 'yes' }]);
expect(getJobSpecs()).to.deep.equal([{ 'a-spec': 'yes' }]);

let newTicket = await jobTicketsQueries.find(newJobId!);
let newTicket = await jobTicketsQueries.find({ id: newJobId! });

expect(newTicket?.ownerAddress).to.equal(stubUserAddress);
expect(newTicket?.jobType).to.equal('a-job');
Expand Down
68 changes: 67 additions & 1 deletion packages/hub/node-tests/routes/profile-purchases-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe('POST /api/profile-purchases', function () {
let cardSpaceRecord = (await cardSpacesQueries.query({ merchantId }))[0];
expect(cardSpaceRecord).to.exist;

let jobTicketRecord = await jobTicketsQueries.find(jobTicketId!);
let jobTicketRecord = await jobTicketsQueries.find({ id: jobTicketId! });
expect(jobTicketRecord?.state).to.equal('pending');
expect(jobTicketRecord?.ownerAddress).to.equal(stubUserAddress);
expect(jobTicketRecord?.payload).to.deep.equal({ 'job-ticket-id': jobTicketId, 'merchant-info-id': merchantId });
Expand All @@ -166,6 +166,7 @@ describe('POST /api/profile-purchases', function () {
receipt: {
'a-receipt': 'yes',
},
somethingelse: 'hmm',
},
});

Expand Down Expand Up @@ -233,6 +234,71 @@ describe('POST /api/profile-purchases', function () {
.expect('Content-Type', 'application/vnd.api+json');
});

it('rejects when the purchase receipt has already been used', async function () {
await jobTicketsQueries.insert({
id: shortUUID.uuid(),
jobType: 'create-profile',
ownerAddress: '0xsomeoneelse',
payload: {
'another-payload': 'okay',
},
sourceArguments: {
provider: 'a-provider',
receipt: {
'a-receipt': 'yes',
},
},
});

await request()
.post(`/api/profile-purchases`)
.send({
data: {
type: 'profile-purchases',
attributes: {
provider: 'a-provider',
receipt: {
'a-receipt': 'yes',
},
extraneous: 'hello',
},
},
relationships: {
'merchant-info': {
data: {
type: 'merchant-infos',
lid: '1',
},
},
},
included: [
{
type: 'merchant-infos',
lid: '1',
attributes: {
name: 'Satoshi Nakamoto',
slug: 'satoshi',
color: 'ff0000',
'text-color': 'ffffff',
},
},
],
})
.set('Authorization', 'Bearer abc123--def456--ghi789')
.set('Content-Type', 'application/vnd.api+json')
.expect(422)
.expect('Content-Type', 'application/vnd.api+json')
.expect({
errors: [
{
status: '422',
title: 'Invalid purchase receipt',
detail: 'Purchase receipt is not valid',
},
],
});
});

it('rejects when the purchase receipt is invalid', async function () {
shouldValidatePurchase = false;
purchaseValidationResponse = {
Expand Down
6 changes: 3 additions & 3 deletions packages/hub/node-tests/tasks/create-profile-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe('CreateProfileTask', function () {
expect(getJobIdentifiers()[0]).to.equal('persist-off-chain-merchant-info');
expect(getJobPayloads()[0]).to.deep.equal({ 'merchant-safe-id': merchantInfosId });

let jobTicket = await jobTicketsQueries.find(jobTicketId);
let jobTicket = await jobTicketsQueries.find({ id: jobTicketId });
expect(jobTicket?.state).to.equal('success');
expect(jobTicket?.result).to.deep.equal({ 'merchant-safe-id': mockMerchantSafeAddress });
});
Expand All @@ -119,7 +119,7 @@ describe('CreateProfileTask', function () {
'merchant-info-id': merchantInfosId,
});

let jobTicket = await jobTicketsQueries.find(jobTicketId);
let jobTicket = await jobTicketsQueries.find({ id: jobTicketId });
expect(jobTicket?.state).to.equal('failed');
expect(jobTicket?.result).to.deep.equal({ error: 'Error: registering should error' });

Expand All @@ -138,7 +138,7 @@ describe('CreateProfileTask', function () {
'merchant-info-id': merchantInfosId,
});

let jobTicket = await jobTicketsQueries.find(jobTicketId);
let jobTicket = await jobTicketsQueries.find({ id: jobTicketId });
expect(jobTicket?.state).to.equal('failed');
expect(jobTicket?.result).to.deep.equal({
error: `Error: subgraph query for transaction ${mockTransactionHash} returned no results`,
Expand Down
27 changes: 8 additions & 19 deletions packages/hub/queries/job-tickets.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
import DatabaseManager from '@cardstack/db';
import { inject } from '@cardstack/di';
import { JobTicket } from '../routes/job-tickets';
import { buildConditions } from '../utils/queries';

export type JobTicketsQueriesFilter = Partial<Pick<JobTicket, 'id' | 'jobType' | 'ownerAddress' | 'sourceArguments'>>;

export default class JobTicketsQueries {
databaseManager: DatabaseManager = inject('database-manager', { as: 'databaseManager' });

async find(id: string): Promise<JobTicket | null> {
let db = await this.databaseManager.getClient();

let query = `SELECT * FROM job_tickets WHERE ID = $1`;
let queryResult = await db.query(query, [id]);

if (queryResult.rows.length) {
let row = queryResult.rows[0];

return mapRowToModel(row);
} else {
return null;
}
}

async findAlreadyCreated(jobType: string, ownerAddress: string, attributes: any): Promise<JobTicket | null> {
async find(filter: JobTicketsQueriesFilter): Promise<JobTicket | null> {
let db = await this.databaseManager.getClient();

let query = `SELECT * FROM job_tickets WHERE job_type = $1 AND owner_address = $2 AND source_arguments = $3`;
let queryResult = await db.query(query, [jobType, ownerAddress, attributes]);
let conditions = buildConditions(filter);
let query = `SELECT * FROM job_tickets WHERE ${conditions.where}`;
let queryResult = await db.query(query, conditions.values);

if (queryResult.rows.length) {
let row = queryResult.rows[0];
Expand All @@ -43,7 +32,7 @@ export default class JobTicketsQueries {
[model.id, model.jobType, model.ownerAddress, model.payload, model.spec, model.sourceArguments]
);

return this.find(model.id!);
return this.find({ id: model.id! });
}

async update(id: string, result: any, state: string) {
Expand Down
4 changes: 2 additions & 2 deletions packages/hub/routes/job-tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default class JobTicketsRoute {
}

let id = ctx.params.id;
let jobTicket = await this.jobTicketsQueries.find(id);
let jobTicket = await this.jobTicketsQueries.find({ id });

if (!jobTicket) {
ctx.body = {
Expand Down Expand Up @@ -81,7 +81,7 @@ export default class JobTicketsRoute {
}

let id = ctx.params.id;
let jobTicketToRetry = await this.jobTicketsQueries.find(id);
let jobTicketToRetry = await this.jobTicketsQueries.find({ id });

if (!jobTicketToRetry) {
ctx.body = {
Expand Down
38 changes: 31 additions & 7 deletions packages/hub/routes/profile-purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,17 @@ export default class ProfilePurchasesRoute {
return;
}

let alreadyCreatedJobTicket = await this.jobTicketsQueries.findAlreadyCreated(
'create-profile',
ctx.state.userAddress,
ctx.request.body.data.attributes
);
let { provider, receipt } = ctx.request.body.data.attributes || {};
let sourceArguments = {
provider,
receipt,
};

let alreadyCreatedJobTicket = await this.jobTicketsQueries.find({
jobType: 'create-profile',
ownerAddress: ctx.state.userAddress,
sourceArguments,
});

if (alreadyCreatedJobTicket) {
ctx.status = 200;
Expand Down Expand Up @@ -142,7 +148,25 @@ export default class ProfilePurchasesRoute {
return;
}

let { provider, receipt } = ctx.request.body.data.attributes;
let alreadyUsedReceipt = await this.jobTicketsQueries.find({
jobType: 'create-profile',
sourceArguments,
});

if (alreadyUsedReceipt) {
ctx.status = 422;
ctx.body = {
errors: [
{
status: '422',
title: 'Invalid purchase receipt',
detail: 'Purchase receipt is not valid',
},
],
};
ctx.type = 'application/vnd.api+json';
return;
}

let { valid: purchaseValidationResult, response: purchaseValidationResponse } = await this.inAppPurchases.validate(
provider,
Expand Down Expand Up @@ -189,7 +213,7 @@ export default class ProfilePurchasesRoute {
ownerAddress: ctx.state.userAddress,
payload: { 'merchant-info-id': merchantInfoId, 'job-ticket-id': jobTicketId },
spec: { maxAttempts: 1 },
sourceArguments: ctx.request.body.data.attributes,
sourceArguments,
};

let insertedJobTicket = await this.jobTicketsQueries.insert(jobTicket as unknown as JobTicket);
Expand Down

0 comments on commit d7ef333

Please sign in to comment.