Skip to content

Commit 1c5d459

Browse files
committed
Send Authorization header on Scratch project save
1 parent 7503251 commit 1c5d459

File tree

4 files changed

+224
-2
lines changed

4 files changed

+224
-2
lines changed

src/scratch.jsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { compose } from "redux";
66
import GUI, { AppStateHOC } from "@scratch/scratch-gui";
77
import ScratchIntegrationHOC from "./components/ScratchEditor/ScratchIntegrationHOC.jsx";
88
import dedupeScratchWarnings from "./utils/dedupeScratchWarnings.js";
9+
import scratchProjectSave from "./utils/scratchProjectSave.js";
910

1011
import ScratchStyles from "./assets/stylesheets/Scratch.scss";
1112

@@ -80,6 +81,7 @@ if (!projectId) {
8081
requiresAuth: false,
8182
latestAccessToken: null,
8283
};
84+
let scratchFetchApi = null;
8385

8486
const getTimeoutMessage = (handshake) =>
8587
handshake.requiresAuth && !handshake.latestAccessToken
@@ -92,6 +94,16 @@ if (!projectId) {
9294
event.data?.type === "scratch-gui-set-token" &&
9395
event.data?.nonce === nonce;
9496

97+
const handleUpdateProjectData = async (currentProjectId, vmState, params) => {
98+
return scratchProjectSave({
99+
scratchFetchApi,
100+
apiUrl,
101+
currentProjectId,
102+
vmState,
103+
params,
104+
});
105+
};
106+
95107
const mountGui = (accessToken) => {
96108
if (isMounted) return;
97109
isMounted = true;
@@ -108,10 +120,12 @@ if (!projectId) {
108120
assetHost={`${apiUrl}/api/scratch/assets`}
109121
basePath={`${process.env.ASSETS_URL}/scratch-gui/`}
110122
onStorageInit={(storage) => {
123+
scratchFetchApi = storage.scratchFetch;
111124
if (accessToken) {
112-
storage.scratchFetch.setMetadata("Authorization", accessToken);
125+
scratchFetchApi.setMetadata("Authorization", accessToken);
113126
}
114127
}}
128+
onUpdateProjectData={handleUpdateProjectData}
115129
onUpdateProjectId={handleUpdateProjectId}
116130
onShowCreatingRemixAlert={handleRemixingStarted}
117131
onShowRemixSuccessAlert={handleRemixingSucceeded}

src/scratch.test.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ jest.mock("./components/ScratchEditor/ScratchIntegrationHOC.jsx", () => ({
44
__esModule: true,
55
default: (WrappedComponent) => WrappedComponent,
66
}));
7+
const mockScratchProjectSave = jest.fn();
8+
jest.mock("./utils/scratchProjectSave.js", () => ({
9+
__esModule: true,
10+
default: (params) => mockScratchProjectSave(params),
11+
}));
712
jest.mock("@scratch/scratch-gui", () => {
813
const MockGui = () => null;
914
MockGui.setAppElement = jest.fn();
@@ -13,9 +18,11 @@ jest.mock("@scratch/scratch-gui", () => {
1318
AppStateHOC: (WrappedComponent) => WrappedComponent,
1419
};
1520
});
21+
22+
const mockRenderRoot = jest.fn();
1623
jest.mock("react-dom/client", () => ({
1724
createRoot: jest.fn(() => ({
18-
render: jest.fn(),
25+
render: mockRenderRoot,
1926
})),
2027
}));
2128

@@ -69,6 +76,8 @@ describe("scratch handshake retries", () => {
6976
beforeEach(() => {
7077
jest.useFakeTimers();
7178
jest.resetModules();
79+
mockRenderRoot.mockClear();
80+
mockScratchProjectSave.mockClear();
7281
process.env = {
7382
...originalEnv,
7483
ASSETS_URL: "https://assets.example.com",
@@ -124,6 +133,42 @@ describe("scratch handshake retries", () => {
124133
expectRetriesStopped(callsAfterHandshake);
125134
});
126135

136+
test("routes project saves through scratchFetch metadata after storage init", async () => {
137+
loadScratchModule();
138+
139+
const nonce = getHandshakeNonce();
140+
dispatchSetTokenMessage({ nonce, accessToken: "token-123" });
141+
142+
const renderedTree = mockRenderRoot.mock.calls[0][0];
143+
const scratchGuiElement = renderedTree.props.children[1];
144+
const scratchStorage = {
145+
scratchFetch: {
146+
setMetadata: jest.fn(),
147+
},
148+
};
149+
150+
scratchGuiElement.props.onStorageInit(scratchStorage);
151+
await scratchGuiElement.props.onUpdateProjectData(
152+
"project-123",
153+
'{"targets":[]}',
154+
{ title: "Saved from test" },
155+
);
156+
157+
expect(scratchStorage.scratchFetch.setMetadata).toHaveBeenCalledWith(
158+
"Authorization",
159+
"token-123",
160+
);
161+
expect(mockScratchProjectSave).toHaveBeenCalledWith(
162+
expect.objectContaining({
163+
scratchFetchApi: scratchStorage.scratchFetch,
164+
apiUrl: "https://api.example.com",
165+
currentProjectId: "project-123",
166+
vmState: '{"targets":[]}',
167+
params: { title: "Saved from test" },
168+
}),
169+
);
170+
});
171+
127172
test("keeps retrying when auth is required but token is missing", () => {
128173
loadScratchModule();
129174

src/utils/scratchProjectSave.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
const buildScratchProjectSaveRequest = ({
2+
apiUrl,
3+
currentProjectId,
4+
params = {},
5+
}) => {
6+
const creatingProject =
7+
currentProjectId === null || typeof currentProjectId === "undefined";
8+
const searchParams = new URLSearchParams(
9+
Object.entries({
10+
original_id: params.originalId,
11+
is_copy: params.isCopy,
12+
is_remix: params.isRemix,
13+
title: params.title,
14+
}).filter(([, value]) => value !== undefined),
15+
);
16+
const queryString = searchParams.toString();
17+
const baseUrl = creatingProject
18+
? `${apiUrl}/api/scratch/projects/`
19+
: `${apiUrl}/api/scratch/projects/${currentProjectId}`;
20+
21+
return {
22+
creatingProject,
23+
method: creatingProject ? "post" : "put",
24+
url: queryString ? `${baseUrl}?${queryString}` : baseUrl,
25+
};
26+
};
27+
28+
const normalizeScratchProjectSaveResponse = async ({
29+
response,
30+
creatingProject,
31+
currentProjectId,
32+
}) => {
33+
if (response.status !== 200) {
34+
throw response.status;
35+
}
36+
37+
const body = await response.json();
38+
return {
39+
...body,
40+
id: creatingProject ? body["content-name"] : currentProjectId,
41+
};
42+
};
43+
44+
const scratchProjectSave = async ({
45+
scratchFetchApi,
46+
apiUrl,
47+
currentProjectId,
48+
vmState,
49+
params,
50+
}) => {
51+
const { creatingProject, method, url } = buildScratchProjectSaveRequest({
52+
apiUrl,
53+
currentProjectId,
54+
params,
55+
});
56+
const response = await scratchFetchApi.scratchFetch(url, {
57+
method,
58+
body: vmState,
59+
headers: {
60+
"Content-Type": "application/json",
61+
},
62+
credentials: "include",
63+
});
64+
65+
return normalizeScratchProjectSaveResponse({
66+
response,
67+
creatingProject,
68+
currentProjectId,
69+
});
70+
};
71+
72+
export default scratchProjectSave;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import scratchProjectSave from "./scratchProjectSave";
2+
3+
describe("scratchProjectSave", () => {
4+
const buildScratchFetchApi = () => ({
5+
scratchFetch: jest.fn(),
6+
});
7+
8+
test("updates an existing project through scratchFetch", async () => {
9+
const scratchFetchApi = buildScratchFetchApi();
10+
scratchFetchApi.scratchFetch.mockResolvedValue({
11+
status: 200,
12+
json: jest.fn().mockResolvedValue({ ok: true }),
13+
});
14+
15+
const response = await scratchProjectSave({
16+
scratchFetchApi,
17+
apiUrl: "https://api.example.com",
18+
currentProjectId: "project-123",
19+
vmState: '{"targets":[]}',
20+
params: { title: "Saved from test" },
21+
});
22+
23+
expect(scratchFetchApi.scratchFetch).toHaveBeenCalledWith(
24+
"https://api.example.com/api/scratch/projects/project-123?title=Saved+from+test",
25+
{
26+
method: "put",
27+
body: '{"targets":[]}',
28+
headers: {
29+
"Content-Type": "application/json",
30+
},
31+
credentials: "include",
32+
},
33+
);
34+
expect(response).toEqual({ ok: true, id: "project-123" });
35+
});
36+
37+
test("creates a project and returns the created id", async () => {
38+
const scratchFetchApi = buildScratchFetchApi();
39+
scratchFetchApi.scratchFetch.mockResolvedValue({
40+
status: 200,
41+
json: jest
42+
.fn()
43+
.mockResolvedValue({ "content-name": "created-project-id" }),
44+
});
45+
46+
const response = await scratchProjectSave({
47+
scratchFetchApi,
48+
apiUrl: "https://api.example.com",
49+
currentProjectId: undefined,
50+
vmState: '{"targets":[]}',
51+
params: {
52+
originalId: "source-project",
53+
isRemix: 1,
54+
title: "Created from test",
55+
},
56+
});
57+
58+
expect(scratchFetchApi.scratchFetch).toHaveBeenCalledWith(
59+
"https://api.example.com/api/scratch/projects/?original_id=source-project&is_remix=1&title=Created+from+test",
60+
{
61+
method: "post",
62+
body: '{"targets":[]}',
63+
headers: {
64+
"Content-Type": "application/json",
65+
},
66+
credentials: "include",
67+
},
68+
);
69+
expect(response).toEqual({
70+
"content-name": "created-project-id",
71+
id: "created-project-id",
72+
});
73+
});
74+
75+
test("rejects with the response status when the save fails", async () => {
76+
const scratchFetchApi = buildScratchFetchApi();
77+
scratchFetchApi.scratchFetch.mockResolvedValue({
78+
status: 401,
79+
json: jest.fn(),
80+
});
81+
82+
await expect(
83+
scratchProjectSave({
84+
scratchFetchApi,
85+
apiUrl: "https://api.example.com",
86+
currentProjectId: "project-123",
87+
vmState: '{"targets":[]}',
88+
}),
89+
).rejects.toBe(401);
90+
});
91+
});

0 commit comments

Comments
 (0)