Skip to content
Draft
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
8,306 changes: 8,306 additions & 0 deletions cli-migration/after/node-cli-2.17-app/package-lock.json

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions cli-migration/after/node-cli-2.17-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "node-cli-2.17-app",
"private": true,
"license": "UNLICENSED",
"scripts": {
"shopify": "shopify",
"build": "shopify app build",
"dev": "shopify app dev",
"info": "shopify app info",
"scaffold": "shopify app scaffold",
"deploy": "shopify app deploy"
},
"dependencies": {
"@shopify/app": "^3",
"@shopify/cli": "^3"
}
}
2 changes: 2 additions & 0 deletions cli-migration/after/node-cli-2.17-app/shopify.app.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
name = "My Node app from cli 2.17 migrated app to cli 3.0"
scopes = "write_products"
44 changes: 44 additions & 0 deletions cli-migration/after/node-cli-2.17-app/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "shopify-app-node",
"private": true,
"scripts": {
"build": "npm run build:client",
"build:client": "vite build --outDir dist/client",
"debug": "node --inspect-brk server/index.js",
"dev": "cross-env NODE_ENV=development nodemon server/index.js --watch ./server",
"preserve": "npm run build",
"serve": "cross-env NODE_ENV=production node server/index.js",
"start": "npm run serve",
"test": "vitest --reporter=verbose"
},
"type": "module",
"engines": {
"node": ">=16.13.0"
},
"dependencies": {
"@apollo/client": "^3.5.10",
"@shopify/app-bridge": "^2.0.22",
"@shopify/app-bridge-react": "^2.0.26",
"@shopify/app-bridge-utils": "^2.0.26",
"@shopify/polaris": "^9.14.1",
"@shopify/shopify-api": "^3.0.0",
"@vitejs/plugin-react": "1.3.2",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cross-env": "^7.0.3",
"dotenv": "^16.0.0",
"express": "^4.18.1",
"graphql": "^16.5.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"serve-static": "^1.14.1",
"vite": "^2.9.8"
},
"devDependencies": {
"nodemon": "^2.0.16",
"prettier": "^2.6.2",
"pretty-quick": "^3.1.3",
"supertest": "^6.2.3",
"vitest": "^0.10.5"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import path from "path";

const port = 9528;

/**
* @param {string} root
* @param {boolean} isProd
*/
export async function serve(root, isProd) {
if (isProd) {
// build first
const { build } = await import("vite");
await build({
root,
logLevel: "silent",
build: {
target: "esnext",
minify: false,
ssrManifest: true,
outDir: "dist/client",
},
});
}

const { createServer } = await import(
path.resolve(root, "server", "index.js")
);
process.env.PORT = port;
return await createServer(root, isProd);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import request from "supertest";
import { createHmac } from "crypto";
import { Shopify } from "@shopify/shopify-api";
import { describe, expect, test, vi } from "vitest";

import { serve } from "./serve.js";

describe("shopify-app-node server", async () => {
const { app } = await serve(process.cwd(), false);

test("loads html on /", async () => {
const response = await request(app).get("/").set("Accept", "text/html");

expect(response.status).toEqual(200);
});

test.concurrent(
"properly handles nested routes in production mode",
async () => {
const { app: productionApp } = await serve(process.cwd(), true);

const response = await request(productionApp)
.get("/something")
.set("Accept", "text/html");

expect(response.status).toEqual(200);
},
20000
);

test("redirects to auth if the app needs to be [re]installed", async () => {
const response = await request(app)
.get("/?shop=test-shop")
.set("Accept", "text/html");

expect(response.status).toEqual(302);
expect(response.headers.location).toEqual("/auth?shop=test-shop");
});

describe("content-security-policy", () => {
test("sets Content Security Policy for embedded apps", async () => {
Shopify.Context.IS_EMBEDDED_APP = true;

const response = await request(app).get(
"/?shop=test-shop.myshopify.test"
);

expect(response.headers["content-security-policy"]).toEqual(
`frame-ancestors https://test-shop.myshopify.test https://admin.shopify.com;`
);
});

test("sets header correctly when shop is missing", async () => {
Shopify.Context.IS_EMBEDDED_APP = true;

const response = await request(app).get("/");

expect(response.headers["content-security-policy"]).toEqual(
`frame-ancestors 'none';`
);
});

test("sets header correctly when app is not embedded", async () => {
Shopify.Context.IS_EMBEDDED_APP = false;

const response = await request(app).get(
"/?shop=test-shop.myshopify.test"
);

expect(response.headers["content-security-policy"]).toEqual(
`frame-ancestors 'none';`
);
});
});

test("goes to top level auth in oauth flow when there is no cookie", async () => {
const response = await request(app).get("/auth").set("Accept", "text/html");

expect(response.status).toEqual(302);
expect(response.headers.location).toContain(`/auth/toplevel`);
});

test("renders toplevel auth page", async () => {
const host = btoa("test-shop.myshopify.com/admin");

const response = await request(app)
.get(`/auth/toplevel?shop=test-shop&host=${host}`)
.set("Accept", "text/html");

expect(response.status).toEqual(200);
expect(response.text).toContain(`host: '${host}'`);
});

test("goes through oauth flow if there is a top level cookie", async () => {
// get a signed top level cookie from the headers
const { headers } = await request(app).get("/auth/toplevel");

const response = await request(app)
.get("/auth")
.set("Cookie", ...headers["set-cookie"]);

expect(response.status).toEqual(302);
expect(response.headers.location).toContain(`/admin/oauth/authorize`);
});

describe("handles the callback correctly", () => {
test("redirects to / with the shop and host if nothing goes wrong", async () => {
const validateAuthCallback = vi
.spyOn(Shopify.Auth, "validateAuthCallback")
.mockImplementationOnce(() => ({
shop: "test-shop",
scope: "write_products",
}));
vi.spyOn(Shopify.Webhooks.Registry, "register").mockImplementationOnce(
() => ({
APP_UNINSTALLED: {
success: true,
},
})
);

const response = await request(app).get(
"/auth/callback?host=test-shop-host&shop=test-shop"
);

expect(validateAuthCallback).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
{
host: "test-shop-host",
shop: "test-shop",
}
);
expect(app.get("active-shopify-shops")).toEqual({
"test-shop": "write_products",
});
expect(response.status).toEqual(302);
expect(response.headers.location).toEqual(
"/?shop=test-shop&host=test-shop-host"
);
});

test("returns 400 if oauth is invalid", async () => {
vi.spyOn(Shopify.Auth, "validateAuthCallback").mockImplementationOnce(
() => {
throw new Shopify.Errors.InvalidOAuthError("test 400 response");
}
);

const response = await request(app).get(
"/auth/callback?host=test-shop-host"
);

expect(response.status).toEqual(400);
expect(response.text).toContain("test 400 response");
});

test("redirects to auth if cookie is not found", async () => {
vi.spyOn(Shopify.Auth, "validateAuthCallback").mockImplementationOnce(
() => {
throw new Shopify.Errors.CookieNotFound("cookie not found");
}
);

const response = await request(app).get(
"/auth/callback?host=test-shop-host&shop=test-shop"
);

expect(response.status).toEqual(302);
expect(response.headers.location).toEqual("/auth?shop=test-shop");
});

test("redirects to auth if session is not found", async () => {
vi.spyOn(Shopify.Auth, "validateAuthCallback").mockImplementationOnce(
() => {
throw new Shopify.Errors.SessionNotFound("session not found");
}
);

const response = await request(app).get(
"/auth/callback?host=test-shop-host&shop=test-shop"
);

expect(response.status).toEqual(302);
expect(response.headers.location).toEqual("/auth?shop=test-shop");
});

test("returns a 500 error otherwise", async () => {
vi.spyOn(Shopify.Auth, "validateAuthCallback").mockImplementationOnce(
() => {
throw new Error("test 500 response");
}
);

const response = await request(app).get(
"/auth/callback?host=test-shop-host&shop=test-shop"
);

expect(response.status).toEqual(500);
expect(response.text).toContain("test 500 response");
});
});

describe("webhook processing", () => {
const process = vi.spyOn(Shopify.Webhooks.Registry, "process");

test("processes webhooks", async () => {
Shopify.Webhooks.Registry.addHandler("TEST_HELLO", {
path: "/webhooks",
webhookHandler: () => {},
});

const response = await request(app)
.post("/webhooks")
.set(
"X-Shopify-Hmac-Sha256",
createHmac("sha256", Shopify.Context.API_SECRET_KEY)
.update("{}", "utf8")
.digest("base64")
)
.set("X-Shopify-Topic", "TEST_HELLO")
.set("X-Shopify-Shop-Domain", "test-shop")
.send("{}");

expect(response.status).toEqual(200);
expect(process).toHaveBeenCalledTimes(1);
});

test("returns a 500 error if webhooks do not process correctly", async () => {
process.mockImplementationOnce(() => {
throw new Error("test 500 response");
});

const response = await request(app).post("/webhooks");

expect(response.status).toEqual(500);
expect(response.text).toContain("test 500 response");
});

test("does not write to response if webhook processing has already output headers", async () => {
const consoleSpy = vi.spyOn(console, "log");
process.mockImplementationOnce((request, response) => {
response.writeHead(400);
response.end();
throw new Error("something went wrong");
});

const response = await request(app).post("/webhooks");

expect(response.status).toEqual(400);
expect(consoleSpy).toHaveBeenCalled();
expect(consoleSpy.mock.lastCall[0]).toContain("something went wrong");
});
});

describe("graphql proxy", () => {
vi.mock(`${process.cwd()}/server/middleware/verify-request.js`, () => ({
default: vi.fn(() => (req, res, next) => {
next();
}),
}));
const proxy = vi.spyOn(Shopify.Utils, "graphqlProxy");

test("graphql proxy is called & responds with body", async () => {
const body = {
data: {
test: "test",
},
};
proxy.mockImplementationOnce(() => ({
body,
}));

const response = await request(app).post("/graphql").send({
query: "{hello}",
});

expect(proxy).toHaveBeenCalledTimes(1);
expect(response.status).toEqual(200);
expect(response.body).toEqual(body);
});

test("returns a 500 error if graphql proxy fails", async () => {
proxy.mockImplementationOnce(() => {
throw new Error("test 500 response");
});

const response = await request(app).post("/graphql");

expect(response.status).toEqual(500);
expect(response.text).toContain("test 500 response");
});
});
});
Loading