Skip to content

Commit ddb02e4

Browse files
committed
Merge branch 'develop' into toger5/new-MembershipManager
2 parents e8d588d + db7e3e3 commit ddb02e4

16 files changed

Lines changed: 387 additions & 124 deletions

File tree

.github/workflows/sonarcloud.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
steps:
2828
# We create the status here and then update it to success/failure in the `report` stage
2929
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
30-
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
30+
- uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
3131
with:
3232
authToken: ${{ secrets.GITHUB_TOKEN }}
3333
state: pending
@@ -75,7 +75,7 @@ jobs:
7575
7676
- name: "🩻 SonarCloud Scan"
7777
id: sonarcloud
78-
uses: matrix-org/sonarcloud-workflow-action@v3.3
78+
uses: matrix-org/sonarcloud-workflow-action@v4.0
7979
# workflow_run fails report against the develop commit always, we don't want that for PRs
8080
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
8181
with:
@@ -87,7 +87,7 @@ jobs:
8787
revision: ${{ github.event.workflow_run.head_sha }}
8888
token: ${{ secrets.SONAR_TOKEN }}
8989

90-
- uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
90+
- uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
9191
if: always()
9292
with:
9393
authToken: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ jobs:
116116
steps:
117117
- name: Skip SonarCloud on merge queues
118118
if: env.ENABLE_COVERAGE == 'false'
119-
uses: guibranco/github-status-action-v2@7ca807c2ba3401be532d29a876b93262108099fb
119+
uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
120120
with:
121121
authToken: ${{ secrets.GITHUB_TOKEN }}
122122
state: success

babel.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module.exports = {
2626
],
2727
],
2828
plugins: [
29+
["@babel/plugin-proposal-decorators", { version: "2023-11" }],
2930
"@babel/plugin-transform-numeric-separator",
3031
"@babel/plugin-transform-class-properties",
3132
"@babel/plugin-transform-object-rest-spread",

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@babel/core": "^7.12.10",
7373
"@babel/eslint-parser": "^7.12.10",
7474
"@babel/eslint-plugin": "^7.12.10",
75+
"@babel/plugin-proposal-decorators": "^7.25.9",
7576
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
7677
"@babel/plugin-transform-class-properties": "^7.12.1",
7778
"@babel/plugin-transform-numeric-separator": "^7.12.7",
@@ -81,7 +82,7 @@
8182
"@babel/preset-typescript": "^7.12.7",
8283
"@casualbot/jest-sonar-reporter": "2.2.7",
8384
"@peculiar/webcrypto": "^1.4.5",
84-
"@stylistic/eslint-plugin": "^3.0.0",
85+
"@stylistic/eslint-plugin": "^4.0.0",
8586
"@types/content-type": "^1.1.5",
8687
"@types/debug": "^4.1.7",
8788
"@types/jest": "^29.0.0",
@@ -121,7 +122,7 @@
121122
"ts-node": "^10.9.2",
122123
"typedoc": "^0.27.0",
123124
"typedoc-plugin-coverage": "^3.0.0",
124-
"typedoc-plugin-mdn-links": "^4.0.0",
125+
"typedoc-plugin-mdn-links": "^5.0.0",
125126
"typedoc-plugin-missing-exports": "^3.0.0",
126127
"typescript": "^5.4.2"
127128
},

spec/unit/http-api/fetch.spec.ts

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { FetchHttpApi } from "../../../src/http-api/fetch";
2020
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
2121
import {
2222
ClientPrefix,
23+
ConnectionError,
2324
HttpApiEvent,
2425
type HttpApiEventHandlerMap,
2526
IdentityPrefix,
@@ -125,7 +126,7 @@ describe("FetchHttpApi", () => {
125126
).resolves.toBe(text);
126127
});
127128

128-
it("should send token via query params if useAuthorizationHeader=false", () => {
129+
it("should send token via query params if useAuthorizationHeader=false", async () => {
129130
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
130131
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
131132
baseUrl,
@@ -134,19 +135,19 @@ describe("FetchHttpApi", () => {
134135
accessToken: "token",
135136
useAuthorizationHeader: false,
136137
});
137-
api.authedRequest(Method.Get, "/path");
138+
await api.authedRequest(Method.Get, "/path");
138139
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("token");
139140
});
140141

141-
it("should send token via headers by default", () => {
142+
it("should send token via headers by default", async () => {
142143
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
143144
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
144145
baseUrl,
145146
prefix,
146147
fetchFn,
147148
accessToken: "token",
148149
});
149-
api.authedRequest(Method.Get, "/path");
150+
await api.authedRequest(Method.Get, "/path");
150151
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token");
151152
});
152153

@@ -163,7 +164,7 @@ describe("FetchHttpApi", () => {
163164
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBeFalsy();
164165
});
165166

166-
it("should ensure no token is leaked out via query params if sending via headers", () => {
167+
it("should ensure no token is leaked out via query params if sending via headers", async () => {
167168
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
168169
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
169170
baseUrl,
@@ -172,12 +173,12 @@ describe("FetchHttpApi", () => {
172173
accessToken: "token",
173174
useAuthorizationHeader: true,
174175
});
175-
api.authedRequest(Method.Get, "/path", { access_token: "123" });
176+
await api.authedRequest(Method.Get, "/path", { access_token: "123" });
176177
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
177178
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token");
178179
});
179180

180-
it("should not override manually specified access token via query params", () => {
181+
it("should not override manually specified access token via query params", async () => {
181182
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
182183
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
183184
baseUrl,
@@ -186,11 +187,11 @@ describe("FetchHttpApi", () => {
186187
accessToken: "token",
187188
useAuthorizationHeader: false,
188189
});
189-
api.authedRequest(Method.Get, "/path", { access_token: "RealToken" });
190+
await api.authedRequest(Method.Get, "/path", { access_token: "RealToken" });
190191
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("RealToken");
191192
});
192193

193-
it("should not override manually specified access token via header", () => {
194+
it("should not override manually specified access token via header", async () => {
194195
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
195196
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
196197
baseUrl,
@@ -199,16 +200,16 @@ describe("FetchHttpApi", () => {
199200
accessToken: "token",
200201
useAuthorizationHeader: true,
201202
});
202-
api.authedRequest(Method.Get, "/path", undefined, undefined, {
203+
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
203204
headers: { Authorization: "Bearer RealToken" },
204205
});
205206
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer RealToken");
206207
});
207208

208-
it("should not override Accept header", () => {
209+
it("should not override Accept header", async () => {
209210
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
210211
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
211-
api.authedRequest(Method.Get, "/path", undefined, undefined, {
212+
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
212213
headers: { Accept: "text/html" },
213214
});
214215
expect(fetchFn.mock.calls[0][1].headers["Accept"]).toBe("text/html");
@@ -288,7 +289,7 @@ describe("FetchHttpApi", () => {
288289

289290
describe("with a tokenRefreshFunction", () => {
290291
it("should emit logout and throw when token refresh fails", async () => {
291-
const error = new Error("uh oh");
292+
const error = new MatrixError();
292293
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
293294
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
294295
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
@@ -308,6 +309,27 @@ describe("FetchHttpApi", () => {
308309
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
309310
});
310311

312+
it("should not emit logout but still throw when token refresh fails due to transitive fault", async () => {
313+
const error = new ConnectionError("transitive fault");
314+
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
315+
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
316+
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
317+
jest.spyOn(emitter, "emit");
318+
const api = new FetchHttpApi(emitter, {
319+
baseUrl,
320+
prefix,
321+
fetchFn,
322+
tokenRefreshFunction,
323+
accessToken,
324+
refreshToken,
325+
});
326+
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
327+
unknownTokenErr,
328+
);
329+
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
330+
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
331+
});
332+
311333
it("should refresh token and retry request", async () => {
312334
const newAccessToken = "new-access-token";
313335
const newRefreshToken = "new-refresh-token";
@@ -468,4 +490,61 @@ describe("FetchHttpApi", () => {
468490
]
469491
`);
470492
});
493+
494+
it("should not make multiple concurrent refresh token requests", async () => {
495+
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
496+
497+
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
498+
const fetchFn = jest.fn().mockResolvedValue({
499+
ok: false,
500+
status: tokenInactiveError.httpStatus,
501+
async text() {
502+
return JSON.stringify(tokenInactiveError.data);
503+
},
504+
async json() {
505+
return tokenInactiveError.data;
506+
},
507+
headers: {
508+
get: jest.fn().mockReturnValue("application/json"),
509+
},
510+
});
511+
const tokenRefreshFunction = jest.fn().mockReturnValue(deferredTokenRefresh.promise);
512+
513+
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
514+
baseUrl,
515+
prefix,
516+
fetchFn,
517+
doNotAttemptTokenRefresh: false,
518+
tokenRefreshFunction,
519+
accessToken: "ACCESS_TOKEN",
520+
refreshToken: "REFRESH_TOKEN",
521+
});
522+
523+
const prom1 = api.authedRequest(Method.Get, "/path1");
524+
const prom2 = api.authedRequest(Method.Get, "/path2");
525+
526+
await jest.advanceTimersByTimeAsync(10); // wait for requests to fire
527+
expect(fetchFn).toHaveBeenCalledTimes(2);
528+
fetchFn.mockResolvedValue({
529+
ok: true,
530+
status: 200,
531+
async text() {
532+
return "{}";
533+
},
534+
async json() {
535+
return {};
536+
},
537+
headers: {
538+
get: jest.fn().mockReturnValue("application/json"),
539+
},
540+
});
541+
deferredTokenRefresh.resolve({ accessToken: "NEW_ACCESS_TOKEN", refreshToken: "NEW_REFRESH_TOKEN" });
542+
543+
await prom1;
544+
await prom2;
545+
expect(fetchFn).toHaveBeenCalledTimes(4); // 2 original calls + 2 retries
546+
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
547+
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
548+
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
549+
});
471550
});

spec/unit/http-api/index.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@ describe("MatrixHttpApi", () => {
5959
xhr.onreadystatechange?.(new Event("test"));
6060
});
6161

62-
it("should fall back to `fetch` where xhr is unavailable", () => {
62+
it("should fall back to `fetch` where xhr is unavailable", async () => {
6363
globalThis.XMLHttpRequest = undefined!;
6464
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
6565
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
6666
upload = api.uploadContent({} as File);
67+
await upload;
6768
expect(fetchFn).toHaveBeenCalled();
6869
});
6970

spec/unit/matrix-client.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2753,12 +2753,13 @@ describe("MatrixClient", function () {
27532753
// WHEN we call `setAccountData` ...
27542754
const setProm = client.setAccountData(eventType, content);
27552755

2756+
await jest.advanceTimersByTimeAsync(10);
27562757
// THEN, the REST call should have happened, and had the correct content
27572758
const lastCall = fetchMock.lastCall("put-account-data");
27582759
expect(lastCall).toBeDefined();
27592760
expect(lastCall?.[1]?.body).toEqual(JSON.stringify(content));
27602761

2761-
// Even after waiting a bit, the method should not yet have returned
2762+
// Even after waiting a bit more, the method should not yet have returned
27622763
await jest.advanceTimersByTimeAsync(10);
27632764
let finished = false;
27642765
setProm.finally(() => (finished = true));

spec/unit/rust-crypto/rust-crypto.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2318,6 +2318,43 @@ describe("RustCrypto", () => {
23182318
expect(dehydratedDeviceIsDeleted).toBeTruthy();
23192319
});
23202320
});
2321+
2322+
describe("disableKeyStorage", () => {
2323+
it("should disable key storage", async () => {
2324+
const secretStorage = {
2325+
getDefaultKeyId: jest.fn().mockResolvedValue("bloop"),
2326+
setDefaultKeyId: jest.fn(),
2327+
store: jest.fn(),
2328+
} as unknown as ServerSideSecretStorage;
2329+
2330+
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
2331+
2332+
let backupIsDeleted = false;
2333+
fetchMock.delete("path:/_matrix/client/v3/room_keys/version/1", () => {
2334+
backupIsDeleted = true;
2335+
return {};
2336+
});
2337+
2338+
let dehydratedDeviceIsDeleted = false;
2339+
fetchMock.delete("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", () => {
2340+
dehydratedDeviceIsDeleted = true;
2341+
return { device_id: "ADEVICEID" };
2342+
});
2343+
2344+
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
2345+
await rustCrypto.disableKeyStorage();
2346+
2347+
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.master", null);
2348+
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.self_signing", null);
2349+
expect(secretStorage.store).toHaveBeenCalledWith("m.cross_signing.user_signing", null);
2350+
expect(secretStorage.store).toHaveBeenCalledWith("m.megolm_backup.v1", null);
2351+
expect(secretStorage.store).toHaveBeenCalledWith("m.secret_storage.key.bloop", null);
2352+
expect(secretStorage.setDefaultKeyId).toHaveBeenCalledWith(null);
2353+
2354+
expect(backupIsDeleted).toBeTruthy();
2355+
expect(dehydratedDeviceIsDeleted).toBeTruthy();
2356+
});
2357+
});
23212358
});
23222359

23232360
/** Build a MatrixHttpApi instance */

src/crypto-api/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,16 @@ export interface CryptoApi {
629629
*/
630630
resetKeyBackup(): Promise<void>;
631631

632+
/**
633+
* Disables server-side key storage and deletes server-side backups.
634+
* * Deletes the current key backup version, if any (but not any previous versions).
635+
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any
636+
* known 4S data (cross-signing keys and the megolm key backup key).
637+
* * Deletes any dehydrated devices.
638+
* * Sets the "m.org.matrix.custom.backup_disabled" account data flag to indicate that the user has disabled backups.
639+
*/
640+
disableKeyStorage(): Promise<void>;
641+
632642
/**
633643
* Deletes the given key backup.
634644
*

src/http-api/errors.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,18 @@ export class ConnectionError extends Error {
197197
return "ConnectionError";
198198
}
199199
}
200+
201+
/**
202+
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
203+
* and attempting to refresh said token also failed but in a way which was not indicative of token invalidation.
204+
* Assumed to be a temporary failure.
205+
*/
206+
export class TokenRefreshError extends Error {
207+
public constructor(cause?: Error) {
208+
super(cause?.message ?? "");
209+
}
210+
211+
public get name(): string {
212+
return "TokenRefreshError";
213+
}
214+
}

0 commit comments

Comments
 (0)