Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions __tests__/message-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ describe("onMessage", () => {
"identify",
"sync",
"syncBuyback",
"syncBuybackDetail",
"syncBuybackPricing",
"verify-write",
"verify-remove",
],
Expand Down Expand Up @@ -134,6 +136,172 @@ describe("onMessage", () => {
);
});

it("fetches a buy-back detail page by pledge id", async () => {
const sendResponse = vi.fn();
const getToken = vi.fn().mockResolvedValue("test-token");

vi.spyOn(globalThis, "fetch").mockResolvedValue({
status: 200,
text: vi.fn().mockResolvedValue("<html>detail</html>"),
} as any);

await onMessage(
JSON.stringify({ action: "syncBuybackDetail", id: "1000001" }),
sendResponse,
getToken,
"1.0.0"
);

const result = JSON.parse(sendResponse.mock.calls[0]![0]);
expect(result).toEqual({
code: 200,
action: "syncBuybackDetail",
id: "1000001",
payload: "<html>detail</html>",
});
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://robertsspaceindustries.com/pledge/buyback/1000001",
expect.objectContaining({ method: "GET" })
);
});

it.each([["../../account/settings"], ["1000001?x=1"], [""], [undefined]])(
"refuses a buy-back detail id of %s",
async (id) => {
const sendResponse = vi.fn();
const fetchSpy = vi.spyOn(globalThis, "fetch");

await onMessage(
JSON.stringify({ action: "syncBuybackDetail", id }),
sendResponse,
vi.fn().mockResolvedValue("test-token"),
"1.0.0"
);

const result = JSON.parse(sendResponse.mock.calls[0]![0]);
expect(result.code).toBe(400);
expect(fetchSpy).not.toHaveBeenCalled();
}
);

it("answers a buy-back detail request that fails", async () => {
const sendResponse = vi.fn();
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("offline"));
vi.spyOn(console, "error").mockImplementation(() => {});

await onMessage(
JSON.stringify({ action: "syncBuybackDetail", id: "1000001" }),
sendResponse,
vi.fn().mockResolvedValue("test-token"),
"1.0.0"
);

const result = JSON.parse(sendResponse.mock.calls[0]![0]);
expect(result).toEqual({
code: 500,
action: "syncBuybackDetail",
id: "1000001",
error: "Buy-back detail failed",
});
});

it("reads the account's store pricing after asking for a store token", async () => {
const sendResponse = vi.fn();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ success: 1, data: "token" }), { status: 200 })
)
.mockResolvedValueOnce(
new Response(
JSON.stringify([
{
data: {
app: {
pricing: {
currencyCode: "EUR",
exchangeRate: 8800,
taxRate: 1900,
isTaxInclusive: true,
},
},
},
},
]),
{ status: 200 }
)
);

await onMessage(
JSON.stringify({ action: "syncBuybackPricing" }),
sendResponse,
vi.fn().mockResolvedValue("test-token"),
"1.0.0"
);

const result = JSON.parse(sendResponse.mock.calls[0]![0]);
expect(result).toEqual({
code: 200,
action: "syncBuybackPricing",
payload: {
currencyCode: "EUR",
exchangeRate: 8800,
taxRate: 1900,
isTaxInclusive: true,
},
});
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
"https://robertsspaceindustries.com/api/account/v2/setAuthToken",
"https://robertsspaceindustries.com/pledge-store/api/upgrade/v2/graphql",
]);
});

// Without a store token RSI prices in USD whatever the account uses, so
// reading on would convert other currencies with the wrong rate.
it("reads no pricing when RSI refuses the store token", async () => {
const sendResponse = vi.fn();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ success: 0 }), { status: 200 })
);

await onMessage(
JSON.stringify({ action: "syncBuybackPricing" }),
sendResponse,
vi.fn().mockResolvedValue("test-token"),
"1.0.0"
);

const result = JSON.parse(sendResponse.mock.calls[0]![0]);
expect(result.code).toBe(502);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});

it("fails store pricing RSI answers without its rates", async () => {
const sendResponse = vi.fn();
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ success: 1 }), { status: 200 })
)
.mockResolvedValueOnce(
new Response(
JSON.stringify([{ data: { app: { pricing: { currencyCode: "EUR" } } } }]),
{ status: 200 }
)
);

await onMessage(
JSON.stringify({ action: "syncBuybackPricing" }),
sendResponse,
vi.fn().mockResolvedValue("test-token"),
"1.0.0"
);

const result = JSON.parse(sendResponse.mock.calls[0]![0]);
expect(result.code).toBe(502);
});

it("responds to syncBuyback action without token", async () => {
const sendResponse = vi.fn();
const getToken = vi.fn().mockResolvedValue(null);
Expand Down
96 changes: 96 additions & 0 deletions lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {
identify,
fetchPledges,
fetchBuybacks,
fetchBuybackDetail,
setStoreAuthToken,
fetchStorePricing,
fetchCitizenPage,
updateBio,
} from "./rsi";
Expand All @@ -21,6 +24,8 @@ export const SUPPORTED_ACTIONS = [
"identify",
"sync",
"syncBuyback",
"syncBuybackDetail",
"syncBuybackPricing",
"verify-write",
"verify-remove",
] as const;
Expand Down Expand Up @@ -93,6 +98,55 @@ async function verifyBio(
return { code: 200, action, payload: { handle, changed } };
}

// The page asking can be any script on a FleetYards origin, so it only ever
// names a pledge by its numeric id and never a URL of its own.
const PLEDGE_ID_PATTERN = /^\d{1,12}$/;

type StorePricing = {
currencyCode: string;
exchangeRate: number;
taxRate: number;
isTaxInclusive: boolean;
};

function isStorePricing(value: any): value is StorePricing {
return (
typeof value?.currencyCode === "string" &&
Number.isFinite(value?.exchangeRate) &&
Number.isFinite(value?.taxRate) &&
typeof value?.isTaxInclusive === "boolean"
);
}

// The currency, exchange rate and tax RSI converts the account's prices with,
// so FleetYards can turn a buy-back price back into RSI's own USD figure.
async function storePricing(token: string) {
const auth = await setStoreAuthToken(token);
if (!auth.ok || !(await reportsSuccess(auth))) {
return { code: auth.ok ? 502 : auth.status, error: "Store token failed" };
}

const response = await fetchStorePricing(token);
if (!response.ok) {
return { code: response.status, error: "Store pricing failed" };
}

const results: any = await response.json().catch(() => undefined);
const pricing = Array.isArray(results)
? results[0]?.data?.app?.pricing
: undefined;
if (!isStorePricing(pricing)) {
return { code: 502, error: "Store pricing unreadable" };
}

const { currencyCode, exchangeRate, taxRate, isTaxInclusive } = pricing;

return {
code: 200,
payload: { currencyCode, exchangeRate, taxRate, isTaxInclusive },
};
}

export async function onMessage(
rawMessage: string,
sendResponse: SendResponse,
Expand Down Expand Up @@ -159,6 +213,48 @@ export async function onMessage(
})
);
}
} else if (message?.action == "syncBuybackDetail") {
const id = String(message.id ?? "");
const token = await getToken();

if (!PLEDGE_ID_PATTERN.test(id)) {
sendResponse(
JSON.stringify({ code: 400, action: message.action, error: "Invalid pledge id" })
);
} else if (!token) {
sendResponse(
JSON.stringify({ code: 401, action: message.action, id, error: "No RSI session" })
);
} else {
const result = await fetchBuybackDetail(token, id)
.then(async (response) => ({
code: response.status,
payload: await response.text(),
}))
.catch((error) => {
console.error("FY Sync: Buy-back detail failed", error);

return { code: 500, error: "Buy-back detail failed" };
});

sendResponse(JSON.stringify({ action: message.action, id, ...result }));
}
} else if (message?.action == "syncBuybackPricing") {
const token = await getToken();

if (!token) {
sendResponse(
JSON.stringify({ code: 401, action: message.action, error: "No RSI session" })
);
} else {
const result = await storePricing(token).catch((error) => {
console.error("FY Sync: Store pricing failed", error);

return { code: 500, error: "Store pricing failed" };
});

sendResponse(JSON.stringify({ action: message.action, ...result }));
}
} else if (message?.action == "sync" || message?.action == "syncBuyback") {
const token = await getToken();
if (!token) {
Expand Down
37 changes: 37 additions & 0 deletions lib/rsi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@ export function fetchBuybacks(token: string, page = 1) {
return fetchAccountPage("buy-back-pledges", token, page);
}

export function fetchBuybackDetail(token: string, id: string) {
return fetch(`${RSI_BASE_URL}/pledge/buyback/${id}`, {
method: "GET",
headers: {
...HTML_PAGE_HEADERS,
"X-Rsi-Token": token,
},
credentials: "include",
});
}

// The store prices in the account's currency once it has a store token, which
// RSI's own pages ask for the same way before they show a price. Without one
// it answers in USD whatever the account uses.
export function setStoreAuthToken(token: string) {
return fetchRSIApi({
url: `${RSI_BASE_URL}/api/account/v2/setAuthToken`,
payload: {},
rsiToken: token,
});
}

export function fetchStorePricing(token: string) {
return fetchRSIApi({
url: `${RSI_BASE_URL}/pledge-store/api/upgrade/v2/graphql`,
payload: [
{
operationName: "pricing",
variables: {},
query:
"query pricing { app { pricing { currencyCode exchangeRate taxRate isTaxInclusive } } }",
},
],
rsiToken: token,
});
}

export function fetchCitizenPage(handle: string) {
return fetch(`${RSI_BASE_URL}/en/citizens/${encodeURIComponent(handle)}`, {
method: "GET",
Expand Down
Loading