@fedify/vocab 2.4.0-dev.2122 → 2.4.0-dev.2127

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/src/vocab.test.ts CHANGED
@@ -2,6 +2,8 @@ import { mockDocumentLoader, test } from "@fedify/fixture";
2
2
  import {
3
3
  decodeMultibase,
4
4
  type DocumentLoader,
5
+ FetchError,
6
+ getDocumentLoader,
5
7
  LanguageString,
6
8
  parseDecimal,
7
9
  type RemoteDocument,
@@ -14,6 +16,7 @@ import {
14
16
  } from "@fedify/vocab-tools";
15
17
  import { configure, type LogRecord, reset } from "@logtape/logtape";
16
18
  import { pascalCase } from "es-toolkit";
19
+ import fetchMock from "fetch-mock";
17
20
  import {
18
21
  deepStrictEqual,
19
22
  notDeepStrictEqual,
@@ -1804,6 +1807,130 @@ test({
1804
1807
  });
1805
1808
  },
1806
1809
  });
1810
+ test({
1811
+ name: "Announce.getObject() logs suppressed failures as warnings",
1812
+ permissions: { env: true, read: true },
1813
+ async fn() {
1814
+ const records: LogRecord[] = [];
1815
+ const notFoundUrl = "https://example.com/suppressed-not-found";
1816
+ const invalidObjectUrl = "https://example.com/invalid-object";
1817
+
1818
+ await reset();
1819
+ fetchMock.spyGlobal();
1820
+
1821
+ try {
1822
+ await configure({
1823
+ sinks: {
1824
+ buffer(record: LogRecord): void {
1825
+ records.push(record);
1826
+ },
1827
+ },
1828
+ filters: {},
1829
+ loggers: [{ category: [], sinks: ["buffer"] }],
1830
+ });
1831
+
1832
+ fetchMock.get(notFoundUrl, { status: 404 });
1833
+
1834
+ const suppressedFetch = new Announce({
1835
+ object: new URL(notFoundUrl),
1836
+ });
1837
+ deepStrictEqual(
1838
+ await suppressedFetch.getObject({ suppressError: true }),
1839
+ null,
1840
+ );
1841
+ ok(
1842
+ records.some((record) =>
1843
+ record.level === "warning" &&
1844
+ record.rawMessage ===
1845
+ "Failed to fetch document: {status} {url} {headers}"
1846
+ ),
1847
+ );
1848
+ ok(
1849
+ records.some((record) =>
1850
+ record.level === "warning" &&
1851
+ record.rawMessage === "Failed to fetch {url}: {error}"
1852
+ ),
1853
+ );
1854
+ deepStrictEqual(
1855
+ records.some((record) =>
1856
+ record.level === "error" &&
1857
+ (
1858
+ record.rawMessage ===
1859
+ "Failed to fetch document: {status} {url} {headers}" ||
1860
+ record.rawMessage === "Failed to fetch {url}: {error}"
1861
+ )
1862
+ ),
1863
+ false,
1864
+ );
1865
+
1866
+ records.length = 0;
1867
+
1868
+ const unsuppressedFetch = new Announce({
1869
+ object: new URL(notFoundUrl),
1870
+ });
1871
+ await rejects(
1872
+ () => unsuppressedFetch.getObject(),
1873
+ FetchError,
1874
+ );
1875
+ ok(
1876
+ records.some((record) =>
1877
+ record.level === "error" &&
1878
+ record.rawMessage ===
1879
+ "Failed to fetch document: {status} {url} {headers}"
1880
+ ),
1881
+ );
1882
+
1883
+ records.length = 0;
1884
+
1885
+ // deno-lint-ignore require-await
1886
+ const invalidDocumentLoader: DocumentLoader = async (url) => ({
1887
+ contextUrl: null,
1888
+ documentUrl: url,
1889
+ document: null,
1890
+ });
1891
+
1892
+ const suppressedParsing = new Announce({
1893
+ object: new URL(invalidObjectUrl),
1894
+ });
1895
+ deepStrictEqual(
1896
+ await suppressedParsing.getObject({
1897
+ documentLoader: invalidDocumentLoader,
1898
+ suppressError: true,
1899
+ }),
1900
+ null,
1901
+ );
1902
+ ok(
1903
+ records.some((record) =>
1904
+ record.level === "warning" &&
1905
+ record.rawMessage === "Failed to parse {url}: {error}"
1906
+ ),
1907
+ );
1908
+ deepStrictEqual(
1909
+ records.some((record) =>
1910
+ record.level === "error" &&
1911
+ record.rawMessage === "Failed to parse {url}: {error}"
1912
+ ),
1913
+ false,
1914
+ );
1915
+
1916
+ records.length = 0;
1917
+
1918
+ const unsuppressedParsing = new Announce({
1919
+ object: new URL(invalidObjectUrl),
1920
+ });
1921
+ await rejects(
1922
+ () =>
1923
+ unsuppressedParsing.getObject({
1924
+ documentLoader: invalidDocumentLoader,
1925
+ }),
1926
+ TypeError,
1927
+ );
1928
+ } finally {
1929
+ fetchMock.hardReset();
1930
+ await reset();
1931
+ }
1932
+ },
1933
+ });
1807
1934
 
1808
1935
  test("Activity.getObject() fetches canonical portable IRIs", async () => {
1809
1936
  const fetchedUrls: string[] = [];
@@ -5559,3 +5686,370 @@ for (const typeUri in types) {
5559
5686
  deepStrictEqual(cls.typeId, new URL(type.uri));
5560
5687
  });
5561
5688
  }
5689
+
5690
+ test("Announce.getObject() suppresses redirect, context, and private URL logs", async () => {
5691
+ const records: LogRecord[] = [];
5692
+ const objectUrl = "https://example.com/suppression-object";
5693
+ const contextUrl = "https://example.com/suppression-context";
5694
+ const scenarios = [
5695
+ "redirect limit",
5696
+ "redirect loop",
5697
+ "context",
5698
+ "private",
5699
+ "private redirect",
5700
+ ];
5701
+ await reset();
5702
+ await configure({
5703
+ sinks: {
5704
+ capture: (record) => {
5705
+ records.push(record);
5706
+ },
5707
+ },
5708
+ loggers: [{
5709
+ category: ["fedify"],
5710
+ lowestLevel: "debug",
5711
+ sinks: ["capture"],
5712
+ }],
5713
+ });
5714
+ try {
5715
+ for (const scenario of scenarios) {
5716
+ fetchMock.spyGlobal();
5717
+ let fetches = 0;
5718
+ fetchMock.get("begin:https://example.com/", (call) => {
5719
+ fetches++;
5720
+ if (scenario === "redirect limit") {
5721
+ return Response.redirect(
5722
+ `https://example.com/redirect-${fetches}`,
5723
+ 302,
5724
+ );
5725
+ }
5726
+ if (scenario === "redirect loop") {
5727
+ return Response.redirect(objectUrl, 302);
5728
+ }
5729
+ if (scenario === "private redirect") {
5730
+ return Response.redirect("http://127.0.0.1/private", 302);
5731
+ }
5732
+ if (call.url === contextUrl) return new Response(null, { status: 404 });
5733
+ return new Response(
5734
+ JSON.stringify({
5735
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
5736
+ id: objectUrl,
5737
+ type: "Note",
5738
+ }),
5739
+ { headers: { "Content-Type": "application/activity+json" } },
5740
+ );
5741
+ });
5742
+ try {
5743
+ for (const suppressError of [true, false, undefined]) {
5744
+ records.length = 0;
5745
+ fetches = 0;
5746
+ const announce = new Announce({
5747
+ object: new URL(
5748
+ scenario === "private" ? "http://127.0.0.1/private" : objectUrl,
5749
+ ),
5750
+ });
5751
+ if (suppressError) {
5752
+ deepStrictEqual(await announce.getObject({ suppressError }), null);
5753
+ deepStrictEqual(
5754
+ records.filter((r) => r.level === "error"),
5755
+ [],
5756
+ scenario,
5757
+ );
5758
+ ok(records.some((r) => r.level === "warning"), scenario);
5759
+ } else {
5760
+ await rejects(() => announce.getObject({ suppressError }));
5761
+ ok(records.some((r) => r.level === "error"), scenario);
5762
+ }
5763
+ if (scenario === "private") deepStrictEqual(fetches, 0);
5764
+ if (scenario === "private redirect") deepStrictEqual(fetches, 1);
5765
+ }
5766
+ } finally {
5767
+ fetchMock.hardReset();
5768
+ }
5769
+ }
5770
+ } finally {
5771
+ await reset();
5772
+ }
5773
+ });
5774
+
5775
+ test("suppressed context loading does not persist on fetched objects", async () => {
5776
+ const states: (boolean | undefined)[] = [];
5777
+ const contextLoader: DocumentLoader = (url, options) => {
5778
+ if (url === "https://www.w3.org/ns/activitystreams") {
5779
+ return mockDocumentLoader(url);
5780
+ }
5781
+ states.push(options?.suppressError);
5782
+ return Promise.resolve({
5783
+ contextUrl: null,
5784
+ documentUrl: url,
5785
+ document: {
5786
+ "@context": {
5787
+ as: "https://www.w3.org/ns/activitystreams#",
5788
+ name: "as:name",
5789
+ },
5790
+ },
5791
+ });
5792
+ };
5793
+ const documentLoader: DocumentLoader = (url) =>
5794
+ Promise.resolve({
5795
+ contextUrl: null,
5796
+ documentUrl: url,
5797
+ document: {
5798
+ "@context": [
5799
+ "https://www.w3.org/ns/activitystreams",
5800
+ "https://example.com/scoped-context",
5801
+ ],
5802
+ id: url,
5803
+ type: url.endsWith("actor") ? "Person" : "Note",
5804
+ attributedTo: "https://example.com/scoped-actor",
5805
+ },
5806
+ });
5807
+ const note = await new Announce({
5808
+ object: new URL("https://example.com/scoped-note"),
5809
+ }).getObject({
5810
+ documentLoader,
5811
+ contextLoader,
5812
+ suppressError: true,
5813
+ });
5814
+ assertInstanceOf(note, Note);
5815
+ ok(states.includes(true));
5816
+ states.length = 0;
5817
+ const actor = await note.getAttribution();
5818
+ assertInstanceOf(actor, Person);
5819
+ ok(states.length > 0);
5820
+ deepStrictEqual(states.includes(true), false);
5821
+ });
5822
+
5823
+ test("attribution chains alternate ordinary and portable suppression", async () => {
5824
+ const states: (boolean | undefined)[] = [];
5825
+ const depths: number[][] = [];
5826
+ let hop = 0;
5827
+ const contextUrl = "https://example.com/attribution-context";
5828
+ const contextLoader: DocumentLoader = (url, options) => {
5829
+ if (url !== contextUrl) return mockDocumentLoader(url, options);
5830
+ states.push(options?.suppressError);
5831
+ const stack = new Error().stack;
5832
+ ok(stack);
5833
+ depths[hop]?.push(stack.split("\n").length);
5834
+ return Promise.resolve({
5835
+ contextUrl: null,
5836
+ documentUrl: url,
5837
+ document: {
5838
+ "@context": { name: "https://www.w3.org/ns/activitystreams#name" },
5839
+ },
5840
+ });
5841
+ };
5842
+ const documentLoader: DocumentLoader = (url) => {
5843
+ const hop = Number(new URL(url).pathname.split("/").at(-1));
5844
+ return Promise.resolve({
5845
+ contextUrl: null,
5846
+ documentUrl: url,
5847
+ document: {
5848
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
5849
+ id: url,
5850
+ type: "Person",
5851
+ name: `Actor ${hop}`,
5852
+ attributedTo: `https://example.com/actors/${hop + 1}`,
5853
+ },
5854
+ });
5855
+ };
5856
+ let person = new Person({
5857
+ id: new URL("https://example.com/actors/root"),
5858
+ attribution: new URL("https://example.com/actors/0"),
5859
+ }, { documentLoader, contextLoader });
5860
+ const stackTraceLimit = globalThis.Object.getOwnPropertyDescriptor(
5861
+ Error,
5862
+ "stackTraceLimit",
5863
+ );
5864
+ try {
5865
+ ok(Reflect.set(Error, "stackTraceLimit", Infinity));
5866
+ for (; hop < 100; hop++) {
5867
+ depths[hop] = [];
5868
+ const next = await person.getAttribution({
5869
+ suppressError: true,
5870
+ ...(hop % 2 === 0 ? {} : {
5871
+ verifyPortableObject: () =>
5872
+ Promise.resolve({ verified: true as const }),
5873
+ }),
5874
+ });
5875
+ assertInstanceOf(next, Person);
5876
+ deepStrictEqual(next.id, new URL(`https://example.com/actors/${hop}`));
5877
+ person = next;
5878
+ }
5879
+ // Compare the same mode after warmup; a retained wrapper per hop would
5880
+ // increase the synchronous call depth even before the stack overflows.
5881
+ ok(depths.every((values) => values.length > 0));
5882
+ deepStrictEqual(depths[98], depths[2]);
5883
+ deepStrictEqual(depths[99], depths[3]);
5884
+ ok(states.length >= 100);
5885
+ ok(states.every((state) => state === true));
5886
+ states.length = 0;
5887
+ assertInstanceOf(await person.getAttribution(), Person);
5888
+ ok(states.length > 0);
5889
+ ok(states.every((state) => state !== true));
5890
+ } finally {
5891
+ if (stackTraceLimit == null) {
5892
+ Reflect.deleteProperty(Error, "stackTraceLimit");
5893
+ } else {
5894
+ globalThis.Object.defineProperty(
5895
+ Error,
5896
+ "stackTraceLimit",
5897
+ stackTraceLimit,
5898
+ );
5899
+ }
5900
+ }
5901
+ });
5902
+
5903
+ test("portable accessors suppress document and context failure logs", async () => {
5904
+ const records: LogRecord[] = [];
5905
+ const id = new URL("ap+ef61://did%3Akey%3Az6Mkabc/objects/1");
5906
+ const contextUrl = "https://example.com/portable-suppression-context";
5907
+ await reset();
5908
+ await configure({
5909
+ sinks: {
5910
+ capture: (record) => {
5911
+ records.push(record);
5912
+ },
5913
+ },
5914
+ loggers: [{
5915
+ category: ["fedify"],
5916
+ lowestLevel: "debug",
5917
+ sinks: ["capture"],
5918
+ }],
5919
+ });
5920
+ try {
5921
+ for (const failContext of [false, true]) {
5922
+ fetchMock.spyGlobal();
5923
+ fetchMock.get("begin:https://example.com/", (call) => {
5924
+ if (!failContext || call.url === contextUrl) {
5925
+ return new Response(null, { status: 404 });
5926
+ }
5927
+ return new Response(
5928
+ JSON.stringify({
5929
+ "@context": ["https://www.w3.org/ns/activitystreams", contextUrl],
5930
+ id: "ap://did:key:z6Mkabc/objects/1",
5931
+ type: "Note",
5932
+ }),
5933
+ { headers: { "Content-Type": "application/activity+json" } },
5934
+ );
5935
+ });
5936
+ try {
5937
+ for (const suppressError of [true, false, undefined]) {
5938
+ records.length = 0;
5939
+ const announce = new Announce({ object: id });
5940
+ const options = {
5941
+ suppressError,
5942
+ gateways: ["https://example.com/"],
5943
+ verifyPortableObject: () =>
5944
+ Promise.resolve({ verified: true as const }),
5945
+ };
5946
+ if (suppressError) {
5947
+ deepStrictEqual(await announce.getObject(options), null);
5948
+ deepStrictEqual(records.filter((r) => r.level === "error"), []);
5949
+ ok(records.some((r) => r.level === "warning"));
5950
+ } else {
5951
+ await rejects(() => announce.getObject(options));
5952
+ ok(records.some((r) => r.level === "error"));
5953
+ }
5954
+ }
5955
+ } finally {
5956
+ fetchMock.hardReset();
5957
+ }
5958
+ }
5959
+ } finally {
5960
+ await reset();
5961
+ }
5962
+ });
5963
+
5964
+ test("portable verifiers suppress document logs only during dereferencing", async () => {
5965
+ const records: LogRecord[] = [];
5966
+ const id = new URL("ap+ef61://did%3Akey%3Az6Mkabc/objects/1");
5967
+ const verificationUrl = "https://example.com/verification-document";
5968
+ const missingUrl = "https://example.com/missing-verification-document";
5969
+ const signal = new AbortController().signal;
5970
+ await reset();
5971
+ await configure({
5972
+ sinks: { capture: (record) => records.push(record) },
5973
+ loggers: [{
5974
+ category: ["fedify"],
5975
+ lowestLevel: "debug",
5976
+ sinks: ["capture"],
5977
+ }],
5978
+ });
5979
+ try {
5980
+ for (const failVerification of [false, true]) {
5981
+ fetchMock.spyGlobal();
5982
+ fetchMock.get("begin:https://example.com/.well-known/", {
5983
+ headers: { "Content-Type": "application/activity+json" },
5984
+ body: {
5985
+ "@context": "https://www.w3.org/ns/activitystreams",
5986
+ id: "ap://did:key:z6Mkabc/objects/1",
5987
+ type: "Note",
5988
+ },
5989
+ });
5990
+ fetchMock.get(verificationUrl, {
5991
+ status: failVerification ? 404 : 200,
5992
+ headers: { "Content-Type": "application/ld+json" },
5993
+ body: {},
5994
+ });
5995
+ fetchMock.get(missingUrl, { status: 404 });
5996
+ try {
5997
+ for (const suppressError of [true, false, undefined]) {
5998
+ records.length = 0;
5999
+ const baseLoader = getDocumentLoader();
6000
+ const calls: Parameters<DocumentLoader>[1][] = [];
6001
+ const documentLoader: DocumentLoader = (url, options) => {
6002
+ calls.push(options);
6003
+ return baseLoader(url, options);
6004
+ };
6005
+ let retainedLoader: DocumentLoader | undefined;
6006
+ const loaderOptions = { signal };
6007
+ const announce = new Announce({ object: id });
6008
+ const object = await announce.getObject({
6009
+ suppressError,
6010
+ documentLoader,
6011
+ gateways: ["https://example.com/"],
6012
+ verifyPortableObject: async (_document, options) => {
6013
+ retainedLoader = options.documentLoader;
6014
+ ok(options.documentLoader);
6015
+ await options.documentLoader(verificationUrl, loaderOptions);
6016
+ return { verified: true };
6017
+ },
6018
+ });
6019
+ if (failVerification) {
6020
+ deepStrictEqual(object, null);
6021
+ deepStrictEqual(
6022
+ records.some((record) => record.level === "error"),
6023
+ !suppressError,
6024
+ );
6025
+ ok(records.some((record) => record.level === "warning"));
6026
+ } else {
6027
+ assertInstanceOf(object, Note);
6028
+ }
6029
+ deepStrictEqual(calls.at(-1)?.signal, signal);
6030
+ deepStrictEqual(
6031
+ calls.at(-1)?.suppressError,
6032
+ suppressError ? true : undefined,
6033
+ );
6034
+ deepStrictEqual(loaderOptions, { signal });
6035
+
6036
+ // A verifier may retain the loader after success or rejection.
6037
+ // Suppression must end when the accessor returns in either case.
6038
+ records.length = 0;
6039
+ const releasedLoader = retainedLoader;
6040
+ ok(releasedLoader);
6041
+ await rejects(
6042
+ () => releasedLoader(missingUrl, loaderOptions),
6043
+ FetchError,
6044
+ );
6045
+ deepStrictEqual(calls.at(-1), loaderOptions);
6046
+ ok(records.some((record) => record.level === "error"));
6047
+ }
6048
+ } finally {
6049
+ fetchMock.hardReset();
6050
+ }
6051
+ }
6052
+ } finally {
6053
+ await reset();
6054
+ }
6055
+ });