@decocms/apps-magento 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +47 -0
- package/src/README.md +65 -0
- package/src/__tests__/cart.test.ts +139 -0
- package/src/__tests__/client.test.ts +252 -0
- package/src/__tests__/features.test.ts +51 -0
- package/src/__tests__/graphql.test.ts +183 -0
- package/src/__tests__/newsletter-subscribe.test.ts +86 -0
- package/src/__tests__/product-stockAlert.test.ts +80 -0
- package/src/__tests__/stringifySearchCriteria.test.ts +56 -0
- package/src/__tests__/transform.test.ts +346 -0
- package/src/__tests__/user-loader.test.ts +134 -0
- package/src/__tests__/wishlist-actions.test.ts +140 -0
- package/src/__tests__/wishlist-loader.test.ts +97 -0
- package/src/actions/newsletter/subscribe.ts +40 -0
- package/src/actions/product/stockAlert.ts +67 -0
- package/src/actions/wishlist/addItem.ts +57 -0
- package/src/actions/wishlist/removeItem.ts +59 -0
- package/src/client.ts +228 -0
- package/src/index.ts +11 -0
- package/src/loaders/cart.ts +64 -0
- package/src/loaders/features.ts +16 -0
- package/src/loaders/user.ts +60 -0
- package/src/loaders/wishlist.ts +29 -0
- package/src/middleware.ts +19 -0
- package/src/types.ts +51 -0
- package/src/utils/cacheTimeControl.ts +69 -0
- package/src/utils/client/types.ts +270 -0
- package/src/utils/constants.ts +101 -0
- package/src/utils/graphql-types.ts +99 -0
- package/src/utils/graphql.ts +155 -0
- package/src/utils/stringifySearchCriteria.ts +62 -0
- package/src/utils/transform.ts +283 -0
- package/src/utils/user.ts +16 -0
- package/tsconfig.json +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/apps-magento",
|
|
3
|
+
"version": "7.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco commerce app: Magento integration",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/decocms/blocks.git",
|
|
9
|
+
"directory": "packages/apps-magento"
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./client": "./src/client.ts",
|
|
15
|
+
"./types": "./src/types.ts",
|
|
16
|
+
"./middleware": "./src/middleware.ts",
|
|
17
|
+
"./loaders/*": "./src/loaders/*.ts",
|
|
18
|
+
"./actions/*": "./src/actions/*.ts",
|
|
19
|
+
"./utils/*": "./src/utils/*.ts",
|
|
20
|
+
"./hooks/*": "./src/hooks/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"test": "vitest run --root ../.. packages/apps-magento/",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"lint:unused": "knip"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@decocms/blocks": "7.2.0",
|
|
30
|
+
"@decocms/apps-commerce": "7.2.0",
|
|
31
|
+
"@decocms/tanstack": "7.2.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": "^19.0.0",
|
|
35
|
+
"react-dom": "^19.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/react": "^19.0.0",
|
|
39
|
+
"@types/react-dom": "^19.0.0",
|
|
40
|
+
"knip": "^5.86.0",
|
|
41
|
+
"typescript": "^5.9.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"registry": "https://registry.npmjs.org",
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Magento app — initial scaffold
|
|
2
|
+
|
|
3
|
+
This folder ports the Magento integration from `deco-cx/apps/magento`
|
|
4
|
+
(Fresh/Deno) to `@decocms/apps/magento` (TanStack Start/Node), following
|
|
5
|
+
the same shape as the existing `vtex/` and `shopify/` packages.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
**Initial scaffold** — covers the configure/client surface and 2 reference
|
|
10
|
+
loaders (`features`, `cart`) so that downstream sites can wire magento at
|
|
11
|
+
all. The remaining 20+ loaders/actions exist as production-grade code in
|
|
12
|
+
the original deco-cx/apps repo and need adaptation passes (Deno → Node,
|
|
13
|
+
ctx-based to client-based state access, cookie helpers from
|
|
14
|
+
`@decocms/start/sdk/cookie`).
|
|
15
|
+
|
|
16
|
+
A real-world consumer (deco-sites/granadobr-tanstack) is wiring magento
|
|
17
|
+
in-site today using a thin adapter that wraps the legacy `magento/mod.ts`
|
|
18
|
+
shape. Their adapter is the migration target — once this package covers
|
|
19
|
+
the surface area they need, the in-site copy goes away.
|
|
20
|
+
|
|
21
|
+
## What's here
|
|
22
|
+
|
|
23
|
+
- `client.ts` — `configureMagento({ baseUrl, apiKey, storeId, ... })` +
|
|
24
|
+
`getMagentoConfig()` global accessor. Mirrors the `configureVtex`
|
|
25
|
+
pattern.
|
|
26
|
+
- `types.ts` — request/response shapes shared between loaders.
|
|
27
|
+
- `loaders/features.ts` — returns the feature flags block. The simplest
|
|
28
|
+
loader, used as a smoke test.
|
|
29
|
+
- `loaders/cart.ts` — fetches the customer's active cart by cookie.
|
|
30
|
+
Pulls cookies via `@decocms/start/sdk/cookie`.
|
|
31
|
+
- `middleware.ts` — passthrough today; real-world cart-id reconciliation
|
|
32
|
+
lives in the consumer site for now.
|
|
33
|
+
- `index.ts` — re-export entry.
|
|
34
|
+
|
|
35
|
+
## Pending port (PR follow-ups)
|
|
36
|
+
|
|
37
|
+
| Path | Original location |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `loaders/product/{detailsPage,detailsPageGQL,listingPage,list,relatedProducts}.ts` | `deco-cx/apps/magento/loaders/product/*` |
|
|
40
|
+
| `loaders/{proxy,user,wishlist}.ts` | `deco-cx/apps/magento/loaders/*` |
|
|
41
|
+
| `loaders/routes/getRouteType.ts` | idem |
|
|
42
|
+
| `actions/cart/{addCoupon,addItem,removeCoupon,removeItem,setSimulation,simulation,updateItem}.ts` | `deco-cx/apps/magento/actions/cart/*` |
|
|
43
|
+
| `actions/newsletter/subscribe.ts` | idem |
|
|
44
|
+
| `actions/product/stockAlert.ts` | idem |
|
|
45
|
+
| `actions/wishlist/{addItem,removeItem}.ts` | idem |
|
|
46
|
+
| `utils/{clientGraphql,client,transform,cache,graphql,cart}.ts` | `deco-cx/apps/magento/utils/*` |
|
|
47
|
+
| `hooks/{useCart,useUser,useWishlist}.ts` | `deco-cx/apps/magento/hooks/*` (refactor to react-query, like `vtex/hooks/`) |
|
|
48
|
+
| `inline-loaders/*` | new, follow `vtex/inline-loaders/` shape |
|
|
49
|
+
|
|
50
|
+
**Site-specific extensions (Livelo, Amasty)** in the deco-cx/apps repo
|
|
51
|
+
should stay out of this package — they belong in consumer sites via the
|
|
52
|
+
`ExtensionOf` pattern. Preserving that pattern in the generic loaders
|
|
53
|
+
above is a hard requirement of this port.
|
|
54
|
+
|
|
55
|
+
## Why a stub now
|
|
56
|
+
|
|
57
|
+
The deco-sites/granadobr-tanstack migration hit a HIGH parity finding:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
invoke(magento/loaders/features) failed: handler not found
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
…because no `@decocms/apps/magento/*` resolver existed. The site is
|
|
64
|
+
working around it locally; this PR begins the upstream fix so future
|
|
65
|
+
magento sites don't have to repeat the in-site adapter.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the cart loader.
|
|
3
|
+
*
|
|
4
|
+
* Parity goals against deco-cx/apps/magento/loaders/cart.ts (Fresh/Deno,
|
|
5
|
+
* prod):
|
|
6
|
+
*
|
|
7
|
+
* - Reads `dataservices_cart_id` cookie when caller doesn't supply
|
|
8
|
+
* `cartId` in props (matches `getCartCookie(req.headers)` in prod).
|
|
9
|
+
* - Honors props.cartId override (matches `_cartId ?? getCartCookie()`).
|
|
10
|
+
* - Hits /rest/:site/V1/carts/:cartId — the /rest/ prefix is what the
|
|
11
|
+
* Magento REST API exposes; the legacy clientAdmin's typed key was
|
|
12
|
+
* "GET /rest/:site/V1/carts/:cartId".
|
|
13
|
+
* - Encodes site + cartId so a malicious cookie can't escape the path
|
|
14
|
+
* segment and reach another admin endpoint with the Bearer token.
|
|
15
|
+
* - Returns null when no cookie (anonymous visitor — matches prod's
|
|
16
|
+
* `if (!cartId) return null`).
|
|
17
|
+
* - Returns null on 404 (expired cookie — prod hits this via the same
|
|
18
|
+
* branch but throws on other non-2xx; we surface a plain Error).
|
|
19
|
+
*
|
|
20
|
+
* NOT covered by this initial port (and intentionally not tested here):
|
|
21
|
+
* - Pre-cart `GET /rest/:site/V1/carts/mine` warm-up for logged-in
|
|
22
|
+
* users (the original does it inside a try/catch and discards the
|
|
23
|
+
* result — it's a cache primer, not a correctness invariant).
|
|
24
|
+
* - Parallel /totals fetch + image-pipeline transform — those land in
|
|
25
|
+
* follow-up PRs that port utils/cart.ts + utils/cache.ts.
|
|
26
|
+
*/
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
28
|
+
import { configureMagento } from "../client";
|
|
29
|
+
import cart from "../loaders/cart";
|
|
30
|
+
|
|
31
|
+
function mockResponse(body: unknown, status = 200): Response {
|
|
32
|
+
return new Response(JSON.stringify(body), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "content-type": "application/json" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requestWithCookie(cookie: string): Request {
|
|
39
|
+
const r = new Request("http://localhost/");
|
|
40
|
+
// Headers in a freshly-constructed Request enter "request" guard mode
|
|
41
|
+
// which silently drops `cookie`. Build a Headers manually then attach.
|
|
42
|
+
const headers = new Headers();
|
|
43
|
+
headers.set("cookie", cookie);
|
|
44
|
+
Object.defineProperty(r, "headers", { value: headers, configurable: true });
|
|
45
|
+
return r;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("cart loader", () => {
|
|
49
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
configureMagento({
|
|
53
|
+
baseUrl: "https://loja.example.com/",
|
|
54
|
+
apiKey: "secret",
|
|
55
|
+
storeId: 1,
|
|
56
|
+
site: "example",
|
|
57
|
+
});
|
|
58
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.restoreAllMocks();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null when no cart cookie is present", async () => {
|
|
66
|
+
const req = new Request("http://localhost/");
|
|
67
|
+
const result = await cart(undefined, req);
|
|
68
|
+
expect(result).toBeNull();
|
|
69
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reads cartId from the dataservices_cart_id cookie (JSON-encoded)", async () => {
|
|
73
|
+
fetchSpy.mockResolvedValue(mockResponse({ id: "abc123", items: [] }));
|
|
74
|
+
const req = requestWithCookie('dataservices_cart_id="abc123"');
|
|
75
|
+
await cart(undefined, req);
|
|
76
|
+
const [target] = fetchSpy.mock.calls[0] as [URL];
|
|
77
|
+
expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/abc123");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reads cartId from the cookie when it's not JSON-quoted", async () => {
|
|
81
|
+
fetchSpy.mockResolvedValue(mockResponse({ id: "raw-id", items: [] }));
|
|
82
|
+
const req = requestWithCookie("dataservices_cart_id=raw-id");
|
|
83
|
+
await cart(undefined, req);
|
|
84
|
+
const [target] = fetchSpy.mock.calls[0] as [URL];
|
|
85
|
+
expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/raw-id");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("honors props.cartId override (cookie ignored)", async () => {
|
|
89
|
+
fetchSpy.mockResolvedValue(mockResponse({ id: "from-props", items: [] }));
|
|
90
|
+
const req = requestWithCookie('dataservices_cart_id="from-cookie"');
|
|
91
|
+
await cart({ cartId: "from-props" }, req);
|
|
92
|
+
const [target] = fetchSpy.mock.calls[0] as [URL];
|
|
93
|
+
expect(target.toString()).toContain("/rest/example/V1/carts/from-props");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("URL-encodes the cartId to prevent path injection", async () => {
|
|
97
|
+
// A malicious cookie value tries to break out of the path segment
|
|
98
|
+
// and reach a different admin endpoint with the Bearer attached.
|
|
99
|
+
fetchSpy.mockResolvedValue(mockResponse(null, 404));
|
|
100
|
+
const req = requestWithCookie('dataservices_cart_id="../admin/leak?secret=1"');
|
|
101
|
+
await cart(undefined, req);
|
|
102
|
+
const [target] = fetchSpy.mock.calls[0] as [URL];
|
|
103
|
+
// Encoded: %2F (slash), %3F (?), %3D (=). The trailing
|
|
104
|
+
// "/admin/leak?secret=1" stays inside the path segment.
|
|
105
|
+
expect(target.pathname).toMatch(
|
|
106
|
+
/^\/rest\/example\/V1\/carts\/(\.\.|%2E%2E)%2Fadmin%2Fleak%3Fsecret%3D1$/i,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns null on 404 (expired cart cookie)", async () => {
|
|
111
|
+
fetchSpy.mockResolvedValue(mockResponse(null, 404));
|
|
112
|
+
const req = requestWithCookie('dataservices_cart_id="stale"');
|
|
113
|
+
const result = await cart(undefined, req);
|
|
114
|
+
expect(result).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("throws on non-404 errors", async () => {
|
|
118
|
+
fetchSpy.mockResolvedValue(mockResponse(null, 500));
|
|
119
|
+
const req = requestWithCookie('dataservices_cart_id="abc"');
|
|
120
|
+
await expect(cart(undefined, req)).rejects.toThrow(/cart loader: 500/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("attaches Bearer + x-origin-header (same-origin) on the cart fetch", async () => {
|
|
124
|
+
configureMagento({
|
|
125
|
+
baseUrl: "https://loja.example.com/",
|
|
126
|
+
apiKey: "secret",
|
|
127
|
+
storeId: 1,
|
|
128
|
+
site: "example",
|
|
129
|
+
originHeader: "origin-secret",
|
|
130
|
+
});
|
|
131
|
+
fetchSpy.mockResolvedValue(mockResponse({ id: "abc", items: [] }));
|
|
132
|
+
const req = requestWithCookie('dataservices_cart_id="abc"');
|
|
133
|
+
await cart(undefined, req);
|
|
134
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
135
|
+
const headers = init.headers as Headers;
|
|
136
|
+
expect(headers.get("authorization")).toBe("Bearer secret");
|
|
137
|
+
expect(headers.get("x-origin-header")).toBe("origin-secret");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Magento client config + magentoFetch wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Parity goals — behavior these tests pin down so the port stays aligned
|
|
5
|
+
* with deco-cx/apps/magento (Fresh/Deno, prod):
|
|
6
|
+
*
|
|
7
|
+
* - configureMagento / getMagentoConfig is a write-once-read-many global
|
|
8
|
+
* (mirrors configureVtex). getMagentoConfig() throws before configure().
|
|
9
|
+
* - magentoFetch:
|
|
10
|
+
* • Same-origin: attaches Authorization (Bearer apiKey),
|
|
11
|
+
* x-origin-header (when configured), and a forced Referer pointing
|
|
12
|
+
* at baseUrl — exactly the headers the Fresh `clientAdmin` was
|
|
13
|
+
* built with at App() time.
|
|
14
|
+
* • Cross-origin: strips ALL Magento-only identity headers so the
|
|
15
|
+
* admin Bearer + origin secret never leak to a third party. Caller
|
|
16
|
+
* headers pass through.
|
|
17
|
+
* • authenticated:false opt-out drops Bearer even on same-origin.
|
|
18
|
+
* • Relative paths resolve against baseUrl with proper "/" handling.
|
|
19
|
+
* • Absolute https://… paths bypass baseUrl entirely.
|
|
20
|
+
*/
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
22
|
+
import { configureMagento, getMagentoConfig, magentoFetch } from "../client";
|
|
23
|
+
|
|
24
|
+
// Reset module-global config between tests by re-importing.
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("configureMagento / getMagentoConfig", () => {
|
|
34
|
+
it("throws before configureMagento() is called", async () => {
|
|
35
|
+
const { getMagentoConfig: freshGet } = await import("../client");
|
|
36
|
+
expect(() => freshGet()).toThrow(/configureMagento\(\) must be called/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns the configured value after configureMagento()", async () => {
|
|
40
|
+
const { configureMagento: c, getMagentoConfig: g } = await import("../client");
|
|
41
|
+
c({
|
|
42
|
+
baseUrl: "https://loja.example.com/",
|
|
43
|
+
apiKey: "test-key",
|
|
44
|
+
storeId: 1,
|
|
45
|
+
site: "example",
|
|
46
|
+
});
|
|
47
|
+
expect(g().baseUrl).toBe("https://loja.example.com/");
|
|
48
|
+
expect(g().apiKey).toBe("test-key");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("magentoFetch — same-origin (configured baseUrl)", () => {
|
|
53
|
+
const baseUrl = "https://loja.example.com/";
|
|
54
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
configureMagento({
|
|
58
|
+
baseUrl,
|
|
59
|
+
apiKey: "secret-bearer",
|
|
60
|
+
storeId: 1,
|
|
61
|
+
site: "example",
|
|
62
|
+
originHeader: "origin-secret",
|
|
63
|
+
});
|
|
64
|
+
fetchSpy = vi
|
|
65
|
+
.spyOn(globalThis, "fetch")
|
|
66
|
+
.mockResolvedValue(
|
|
67
|
+
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("attaches Authorization, x-origin-header, and forced Referer", async () => {
|
|
72
|
+
await magentoFetch("/rest/example/V1/carts/123");
|
|
73
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
74
|
+
const headers = init.headers as Headers;
|
|
75
|
+
expect(headers.get("authorization")).toBe("Bearer secret-bearer");
|
|
76
|
+
expect(headers.get("x-origin-header")).toBe("origin-secret");
|
|
77
|
+
expect(headers.get("referer")).toBe(baseUrl);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("resolves relative path against baseUrl with correct slash handling", async () => {
|
|
81
|
+
await magentoFetch("rest/example/V1/carts/123"); // no leading slash
|
|
82
|
+
const [target] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
83
|
+
expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/123");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("authenticated:false suppresses Bearer even on same-origin", async () => {
|
|
87
|
+
await magentoFetch("/rest/example/V1/carts/123", { authenticated: false });
|
|
88
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
89
|
+
const headers = init.headers as Headers;
|
|
90
|
+
expect(headers.get("authorization")).toBeNull();
|
|
91
|
+
// Same-origin still gets the other Magento-identity headers.
|
|
92
|
+
expect(headers.get("x-origin-header")).toBe("origin-secret");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("preserves caller-supplied Referer (no force-overwrite when caller set one)", async () => {
|
|
96
|
+
await magentoFetch("/rest/example/V1/carts/123", {
|
|
97
|
+
headers: { Referer: "https://caller.example/page" },
|
|
98
|
+
});
|
|
99
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
100
|
+
const headers = init.headers as Headers;
|
|
101
|
+
expect(headers.get("referer")).toBe("https://caller.example/page");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("magentoFetch — cross-origin guard", () => {
|
|
106
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
configureMagento({
|
|
110
|
+
baseUrl: "https://loja.example.com/",
|
|
111
|
+
apiKey: "secret-bearer",
|
|
112
|
+
storeId: 1,
|
|
113
|
+
site: "example",
|
|
114
|
+
originHeader: "origin-secret",
|
|
115
|
+
});
|
|
116
|
+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 }));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("strips Bearer when fetching a non-Magento host", async () => {
|
|
120
|
+
await magentoFetch("https://attacker.example/api");
|
|
121
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
122
|
+
const headers = init.headers as Headers;
|
|
123
|
+
expect(headers.get("authorization")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("strips x-origin-header when fetching a non-Magento host", async () => {
|
|
127
|
+
// Regression for cubic review: the previous fix only dropped Bearer
|
|
128
|
+
// while x-origin-header and Referer still leaked.
|
|
129
|
+
await magentoFetch("https://attacker.example/api");
|
|
130
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
131
|
+
const headers = init.headers as Headers;
|
|
132
|
+
expect(headers.get("x-origin-header")).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("strips forced Referer when fetching a non-Magento host", async () => {
|
|
136
|
+
await magentoFetch("https://attacker.example/api");
|
|
137
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
138
|
+
const headers = init.headers as Headers;
|
|
139
|
+
// Referer to https://loja.example.com/ would broadcast the Magento
|
|
140
|
+
// host to the third party — must not be set by us.
|
|
141
|
+
expect(headers.get("referer")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("still forwards caller-supplied headers cross-origin", async () => {
|
|
145
|
+
await magentoFetch("https://partner.example/api", {
|
|
146
|
+
headers: { "x-correlation-id": "abc123" },
|
|
147
|
+
});
|
|
148
|
+
const [, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
149
|
+
const headers = init.headers as Headers;
|
|
150
|
+
expect(headers.get("x-correlation-id")).toBe("abc123");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("treats an absolute URL with the same origin as same-origin", async () => {
|
|
154
|
+
await magentoFetch("https://loja.example.com/rest/example/V1/carts/123");
|
|
155
|
+
const [target, init] = fetchSpy.mock.calls[0] as [URL, RequestInit];
|
|
156
|
+
expect(target.toString()).toBe("https://loja.example.com/rest/example/V1/carts/123");
|
|
157
|
+
expect((init.headers as Headers).get("authorization")).toBe("Bearer secret-bearer");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("initMagentoFromBlocks — secret resolution", () => {
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
vi.resetModules();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns early when the `magento` block is absent", async () => {
|
|
167
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
168
|
+
const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
|
|
169
|
+
await initMagentoFromBlocks({});
|
|
170
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("No `magento` block"));
|
|
171
|
+
expect(() => getMagentoConfig()).toThrow(/configureMagento\(\) must be called/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("reads plain-string apiKey directly", async () => {
|
|
175
|
+
const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
|
|
176
|
+
await initMagentoFromBlocks({
|
|
177
|
+
magento: {
|
|
178
|
+
apiConfig: {
|
|
179
|
+
baseUrl: "https://loja.example.com/",
|
|
180
|
+
apiKey: "plain-string-key",
|
|
181
|
+
site: "example",
|
|
182
|
+
storeId: 1,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
expect(getMagentoConfig().apiKey).toBe("plain-string-key");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("dereferences a Secret-shaped apiKey via process.env (the env fallback path)", async () => {
|
|
190
|
+
process.env.TEST_MAGENTO_KEY = "from-env";
|
|
191
|
+
const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
|
|
192
|
+
await initMagentoFromBlocks({
|
|
193
|
+
magento: {
|
|
194
|
+
apiConfig: {
|
|
195
|
+
baseUrl: "https://loja.example.com/",
|
|
196
|
+
apiKey: {
|
|
197
|
+
__resolveType: "website/loaders/secret.ts",
|
|
198
|
+
name: "TEST_MAGENTO_KEY",
|
|
199
|
+
},
|
|
200
|
+
site: "example",
|
|
201
|
+
storeId: 1,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
expect(getMagentoConfig().apiKey).toBe("from-env");
|
|
206
|
+
delete process.env.TEST_MAGENTO_KEY;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("falls back to empty string when secret is unresolvable", async () => {
|
|
210
|
+
// no DECO_CRYPTO_KEY, no env var with this name, no decrypt
|
|
211
|
+
// → resolveSecret returns null → init writes "".
|
|
212
|
+
delete process.env.DECO_CRYPTO_KEY;
|
|
213
|
+
const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
|
|
214
|
+
await initMagentoFromBlocks({
|
|
215
|
+
magento: {
|
|
216
|
+
apiConfig: {
|
|
217
|
+
baseUrl: "https://loja.example.com/",
|
|
218
|
+
apiKey: {
|
|
219
|
+
__resolveType: "website/loaders/secret.ts",
|
|
220
|
+
encrypted: "deadbeef",
|
|
221
|
+
name: "UNDEFINED_ENV_VAR_DO_NOT_SET",
|
|
222
|
+
},
|
|
223
|
+
site: "example",
|
|
224
|
+
storeId: 1,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
expect(getMagentoConfig().apiKey).toBe("");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("resolves both apiKey and originHeader independently", async () => {
|
|
232
|
+
process.env.TEST_API_KEY = "api-from-env";
|
|
233
|
+
process.env.TEST_ORIGIN = "origin-from-env";
|
|
234
|
+
const { initMagentoFromBlocks, getMagentoConfig } = await import("../client");
|
|
235
|
+
await initMagentoFromBlocks({
|
|
236
|
+
magento: {
|
|
237
|
+
apiConfig: {
|
|
238
|
+
baseUrl: "https://loja.example.com/",
|
|
239
|
+
apiKey: { name: "TEST_API_KEY" },
|
|
240
|
+
originHeader: { name: "TEST_ORIGIN" },
|
|
241
|
+
site: "example",
|
|
242
|
+
storeId: 1,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const cfg = getMagentoConfig();
|
|
247
|
+
expect(cfg.apiKey).toBe("api-from-env");
|
|
248
|
+
expect(cfg.originHeader).toBe("origin-from-env");
|
|
249
|
+
delete process.env.TEST_API_KEY;
|
|
250
|
+
delete process.env.TEST_ORIGIN;
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the features loader.
|
|
3
|
+
*
|
|
4
|
+
* Parity with deco-cx/apps/magento/loaders/features.ts (Fresh/Deno, prod):
|
|
5
|
+
* the legacy loader was a 3-arg `(_props, _req, ctx) => ctx.features`.
|
|
6
|
+
* The ported version uses the module-global config instead of a per-
|
|
7
|
+
* request ctx, but the surface area downstream is the same — a plain
|
|
8
|
+
* object pulled from the resolved magento CMS block.
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { configureMagento } from "../client";
|
|
12
|
+
import features from "../loaders/features";
|
|
13
|
+
|
|
14
|
+
describe("features loader", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
configureMagento({
|
|
17
|
+
baseUrl: "https://loja.example.com/",
|
|
18
|
+
apiKey: "key",
|
|
19
|
+
storeId: 1,
|
|
20
|
+
site: "example",
|
|
21
|
+
features: {
|
|
22
|
+
dangerouslyDisableWishlist: false,
|
|
23
|
+
dangerouslyDisableOnLoadUpdate: true,
|
|
24
|
+
dangerouslyReturnNullAfterAction: true,
|
|
25
|
+
dangerouslyDontReturnCartAfterAction: true,
|
|
26
|
+
dangerouslyDisableOnVisibilityChangeUpdate: true,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns the feature flags object from the config", () => {
|
|
32
|
+
expect(features()).toEqual({
|
|
33
|
+
dangerouslyDisableWishlist: false,
|
|
34
|
+
dangerouslyDisableOnLoadUpdate: true,
|
|
35
|
+
dangerouslyReturnNullAfterAction: true,
|
|
36
|
+
dangerouslyDontReturnCartAfterAction: true,
|
|
37
|
+
dangerouslyDisableOnVisibilityChangeUpdate: true,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns an empty object when features are not configured", () => {
|
|
42
|
+
configureMagento({
|
|
43
|
+
baseUrl: "https://loja.example.com/",
|
|
44
|
+
apiKey: "key",
|
|
45
|
+
storeId: 1,
|
|
46
|
+
site: "example",
|
|
47
|
+
// features omitted
|
|
48
|
+
});
|
|
49
|
+
expect(features()).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
});
|