Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Data masking] Add @unmask directive with field access warnings #11919

Merged
merged 103 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from 91 commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
7841064
Add failing test for unmasked directive
jerelmiller Jun 28, 2024
0105531
Don't send unmasked directive to server
jerelmiller Jun 28, 2024
b76b4ac
Stub isUnmaskedDocument function
jerelmiller Jun 28, 2024
7640ffa
Don't mask when unmasked directive is present
jerelmiller Jun 28, 2024
0706cd2
Add failing tests for checking unmasked directive
jerelmiller Jun 28, 2024
27b79f5
Implement check for unmasked directive
jerelmiller Jun 28, 2024
8c5165f
Show query name in warning
jerelmiller Jun 28, 2024
9c3ff6b
Tweak warning message
jerelmiller Jun 28, 2024
ea6147b
Add additional comment
jerelmiller Jun 28, 2024
7b0bc63
Remove unmasked directive in mock link
jerelmiller Jun 28, 2024
7d22dbe
Fix reference to wrong directive name
jerelmiller Jun 28, 2024
862d1ad
Add test demonstrating document transforms with unmasked work as expe…
jerelmiller Jun 28, 2024
f6d3710
Rename @unmasked to @unmask
jerelmiller Jun 28, 2024
bead73f
Update exports snapshot
jerelmiller Jun 28, 2024
406ba07
Add test for checking warnings on property access for masking
jerelmiller Jul 1, 2024
75c5e22
Add implementation to warn when accessing unmasked field
jerelmiller Jul 1, 2024
52aae2c
Hide unmasked field warning in other tests
jerelmiller Jul 1, 2024
f39bc40
Remove isUnmasked from getDocumentInfo
jerelmiller Jul 1, 2024
c83d8c0
Update comment
jerelmiller Jul 1, 2024
4997232
Allow argument to unmask directive
jerelmiller Jul 2, 2024
7c8e99b
Add test that checks warnOnFieldAccess arg
jerelmiller Jul 2, 2024
eec30a3
Add test checking variables on warnOnFieldAccess
jerelmiller Jul 2, 2024
470e32a
Add test checking non-boolean arg to warnOnFieldAccess
jerelmiller Jul 2, 2024
5d0f2ad
Allow disabling warnings for masked field access with @unmask
jerelmiller Jul 2, 2024
19a72e3
Delete the property with descriptor then set value
jerelmiller Jul 16, 2024
a3963e9
Add test for @unmask in maskQuery
jerelmiller Jul 16, 2024
362b485
Refactor some common data into context object
jerelmiller Jul 16, 2024
81371e4
Add operation name to warning
jerelmiller Jul 16, 2024
23d036c
Add query name to warning
jerelmiller Jul 16, 2024
8c03e90
Add test for accessing shared field
jerelmiller Jul 16, 2024
1fe70af
Minor tweak to test name
jerelmiller Jul 16, 2024
b0a8c15
Fix failing test due to change in mock call
jerelmiller Jul 16, 2024
3db7c6d
Add additional check for number of warnings emitted
jerelmiller Jul 16, 2024
6de39dd
Add test to check warnOnFieldAccess
jerelmiller Jul 16, 2024
abdec8e
Move unmasked to context
jerelmiller Jul 16, 2024
f178ea3
Move matchesFragment to context
jerelmiller Jul 16, 2024
361ee8b
Use case instead of default in switch
jerelmiller Jul 16, 2024
e12cd3f
Rename helper function
jerelmiller Jul 16, 2024
9e4e80a
Make test for unmask a bit more complex
jerelmiller Jul 16, 2024
46195fe
Handle adding field accessor warnings to inline fragments and fragmen…
jerelmiller Jul 16, 2024
ac1a89e
Print field path when accessing masked field
jerelmiller Jul 16, 2024
0a9959a
Adjust test based on change to message change
jerelmiller Jul 16, 2024
b5a3b55
Update warning message
jerelmiller Jul 16, 2024
92b85a2
Ensure warnings for unmasked fields on arrays
jerelmiller Jul 17, 2024
d6672bd
Tweak test descriptions
jerelmiller Jul 17, 2024
1bdc0a1
Handle unmasking and warnings on arrays
jerelmiller Jul 17, 2024
bd873cb
Update api report and size limits
jerelmiller Jul 17, 2024
961daf6
Fix reference to undefined tag
jerelmiller Jul 17, 2024
dfefaac
Remove unneeded concat
jerelmiller Jul 17, 2024
32f94e4
Add helper for determining if fragment is masked
jerelmiller Jul 18, 2024
00dc2d9
Pivot to unmask at fragment level
jerelmiller Jul 18, 2024
e5ed0ae
Return a masking mode for named fragment instead
jerelmiller Jul 18, 2024
004909c
Remove isUnmaskedDocument utility
jerelmiller Jul 18, 2024
bb528ff
Use mask mode to determine when to mask/warn on field access
jerelmiller Jul 18, 2024
a84958b
Add a withProdMode helper
jerelmiller Jul 18, 2024
78c7570
Ensure warnings are disabled in prod
jerelmiller Jul 18, 2024
89a3acc
Swap to check console.warn instead of consoleSpy.warn
jerelmiller Jul 18, 2024
cb98a54
Simplify first unmask case
jerelmiller Jul 18, 2024
5495c3d
Ensure mix and match works with unmask
jerelmiller Jul 18, 2024
d295697
Update test to check for refrential equality on returned object
jerelmiller Jul 18, 2024
f50bce2
Add test for usage of unmask and referential equality
jerelmiller Jul 18, 2024
688653f
Update client tests to use unmask in proper location
jerelmiller Jul 18, 2024
980abca
Deep freeze all objects in masking test
jerelmiller Jul 18, 2024
3501dec
Swap order of fields
jerelmiller Jul 18, 2024
0cc8665
Fix issue where unmask on fragment not last would error
jerelmiller Jul 18, 2024
fa63e68
Remove unused import
jerelmiller Jul 18, 2024
4514ecb
Update snapshot test
jerelmiller Jul 18, 2024
69f8425
Rerun api report and update size limits
jerelmiller Jul 18, 2024
eb0b550
Fix build issue with assigning __DEV__ for withProdMode
jerelmiller Jul 18, 2024
0806fbb
Remove unneed spies in client test
jerelmiller Jul 18, 2024
00e9701
Rename maskQuery to maskOperation
jerelmiller Jul 18, 2024
320473e
Add tests to check against subscription/mutation operations
jerelmiller Jul 18, 2024
053177e
Ensure the operation type is reported correctly in warning
jerelmiller Jul 18, 2024
73be28a
Add more robust checking to document for maskOperation
jerelmiller Jul 18, 2024
7464a94
Pull operationName from the definition instead of helper
jerelmiller Jul 18, 2024
c3e8e09
Set proper operationName for fragment
jerelmiller Jul 18, 2024
ffb5f93
Reorder args to provide default value
jerelmiller Jul 18, 2024
95ccb23
Inline the context
jerelmiller Jul 18, 2024
26d404b
Group maskOperation and maskFragment tests in separate describes
jerelmiller Jul 18, 2024
2164523
Add additional tests to ensure @unmask works with maskFragment
jerelmiller Jul 18, 2024
f4b5734
Remove mode from unmaskFragmentFields
jerelmiller Jul 18, 2024
532f2de
Rename unmaskFragmentFields to addFieldAccessorWarnings
jerelmiller Jul 18, 2024
fdbcbc7
Rename parent to data
jerelmiller Jul 18, 2024
77c636a
Ensure child fragments of migrate mode are handled correctly
jerelmiller Jul 18, 2024
c562e52
Use reduce instead of forEach
jerelmiller Jul 18, 2024
8ff5a7f
Add test for maskFragment on child of @unmask(mode: 'migrate')
jerelmiller Jul 18, 2024
27219ba
Deep freeze the masked value if data is frozen
jerelmiller Jul 18, 2024
2d98317
Regenerate API report and update size limits
jerelmiller Jul 18, 2024
cf4b048
Mark getFragmentMaskMode as internal
jerelmiller Jul 18, 2024
a30bd6c
Rerun api report
jerelmiller Jul 18, 2024
f5352ff
Ensure maskFragment honors frozen objects
jerelmiller Jul 18, 2024
39fb409
Minor perf boost by reassigning getter function instead of value vari…
jerelmiller Jul 24, 2024
435bfea
Clean up Prettier, Size-limit, and Api-Extractor
jerelmiller Jul 24, 2024
042aaf2
Use string concatenation to track path
jerelmiller Jul 24, 2024
43d3389
Run unmask path in prod mode
jerelmiller Jul 24, 2024
7e5ec60
Clean up Prettier, Size-limit, and Api-Extractor
jerelmiller Jul 24, 2024
7a678e9
Run compare build output on all branches
jerelmiller Jul 24, 2024
58bf7d4
Reorder call for migrate path for friendlier prod optimization
jerelmiller Jul 24, 2024
a5832d8
Use || instead of ??
jerelmiller Jul 24, 2024
cccca9c
Clean up Prettier, Size-limit, and Api-Extractor
jerelmiller Jul 24, 2024
3c4df15
Allow undefined for path
jerelmiller Jul 24, 2024
68504eb
Only concat path in dev mode
jerelmiller Jul 24, 2024
a924c0f
Clean up Prettier, Size-limit, and Api-Extractor
jerelmiller Jul 24, 2024
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
2 changes: 1 addition & 1 deletion .api-reports/api-report-cache.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_components.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_context.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_hoc.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_hooks.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_internal.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_ssr.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-testing.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-testing_core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down
5 changes: 4 additions & 1 deletion .api-reports/api-report-utilities.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts
Expand Down Expand Up @@ -1151,6 +1151,9 @@ export function getFragmentDefinitions(doc: DocumentNode): FragmentDefinitionNod
// @public (undocumented)
export function getFragmentFromSelection(selection: SelectionNode, fragmentMap?: FragmentMap | FragmentMapFunction): InlineFragmentNode | FragmentDefinitionNode | null;

// @internal (undocumented)
export function getFragmentMaskMode(fragment: FragmentSpreadNode): "mask" | "migrate" | "unmask";

// @public
export function getFragmentQueryDocument(document: DocumentNode, fragmentName?: string): DocumentNode;

Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {
// (undocumented)
identify(object: StoreObject | Reference): string | undefined;
// (undocumented)
maskDocument<TData = unknown>(document: DocumentNode, data: TData): TData;
maskOperation<TData = unknown>(document: DocumentNode, data: TData): TData;
// (undocumented)
modify<Entity extends Record<string, any> = Record<string, any>>(options: Cache_2.ModifyOptions<Entity>): boolean;
// (undocumented)
Expand Down
4 changes: 2 additions & 2 deletions .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"dist/apollo-client.min.cjs": 39923,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33211
"dist/apollo-client.min.cjs": 40410,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33667
}
1 change: 1 addition & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ Array [
"getFragmentDefinition",
"getFragmentDefinitions",
"getFragmentFromSelection",
"getFragmentMaskMode",
"getFragmentQueryDocument",
"getGraphQLErrorsFromResult",
"getInclusionDirectives",
Expand Down
220 changes: 220 additions & 0 deletions src/__tests__/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Kind,
print,
visit,
FragmentSpreadNode,
} from "graphql";
import gql from "graphql-tag";

Expand Down Expand Up @@ -6599,6 +6600,149 @@ describe("data masking", () => {
}
});

it("does not mask fragments marked with @unmask", async () => {
interface Query {
currentUser: {
__typename: "User";
id: number;
name: string;
};
}

const query: TypedDocumentNode<Query, never> = gql`
query UnmaskedQuery {
currentUser {
id
name
...UserFields @unmask
}
}

fragment UserFields on User {
age
}
`;

const mocks = [
{
request: { query },
result: {
data: {
currentUser: {
__typename: "User",
id: 1,
name: "Test User",
age: 30,
},
},
},
},
];

const client = new ApolloClient({
dataMasking: true,
cache: new InMemoryCache(),
link: new MockLink(mocks),
});

const observable = client.watchQuery({ query });

const stream = new ObservableStream(observable);

{
const { data } = await stream.takeNext();

expect(data).toEqual({
currentUser: {
__typename: "User",
id: 1,
name: "Test User",
age: 30,
},
});
}
});

it("does not mask fragments marked with @unmask added by document transforms", async () => {
const documentTransform = new DocumentTransform((document) => {
return visit(document, {
FragmentSpread(node) {
return {
...node,
directives: [
{
kind: Kind.DIRECTIVE,
name: { kind: Kind.NAME, value: "unmask" },
},
],
} satisfies FragmentSpreadNode;
},
});
});

interface Query {
currentUser: {
__typename: "User";
id: number;
name: string;
};
}

const query: TypedDocumentNode<Query, never> = gql`
query UnmaskedQuery {
currentUser {
id
name
...UserFields
}
}

fragment UserFields on User {
age
}
`;

const mocks = [
{
request: { query },
result: {
data: {
currentUser: {
__typename: "User",
id: 1,
name: "Test User",
age: 30,
},
},
},
},
];

const client = new ApolloClient({
dataMasking: true,
cache: new InMemoryCache(),
link: new MockLink(mocks),
documentTransform,
});

const observable = client.watchQuery({ query });

const stream = new ObservableStream(observable);

{
const { data } = await stream.takeNext();

expect(data).toEqual({
currentUser: {
__typename: "User",
id: 1,
name: "Test User",
age: 30,
},
});
}
});

it("does not mask query when using a cache that does not support it", async () => {
using _ = spyOnConsole("warn");

Expand Down Expand Up @@ -7252,6 +7396,82 @@ describe("data masking", () => {
}
}
);

it("warns when accessing a unmasked field while using @unmask with mode: 'migrate'", async () => {
using consoleSpy = spyOnConsole("warn");

interface Query {
currentUser: {
__typename: "User";
id: number;
name: string;
age: number;
};
}

const query: TypedDocumentNode<Query, never> = gql`
query UnmaskedQuery {
currentUser {
id
name
...UserFields @unmask(mode: "migrate")
}
}

fragment UserFields on User {
age
name
}
`;

const mocks = [
{
request: { query },
result: {
data: {
currentUser: {
__typename: "User",
id: 1,
name: "Test User",
age: 34,
},
},
},
delay: 20,
},
];

const client = new ApolloClient({
dataMasking: true,
cache: new InMemoryCache(),
link: new MockLink(mocks),
});

const observable = client.watchQuery({ query });
const stream = new ObservableStream(observable);

{
const { data } = await stream.takeNext();
data.currentUser.__typename;
data.currentUser.id;
data.currentUser.name;

expect(consoleSpy.warn).not.toHaveBeenCalled();

data.currentUser.age;

expect(consoleSpy.warn).toHaveBeenCalledTimes(1);
expect(consoleSpy.warn).toHaveBeenCalledWith(
"Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.",
"query 'UnmaskedQuery'",
"currentUser.age"
);

// Ensure we only warn once
data.currentUser.age;
expect(consoleSpy.warn).toHaveBeenCalledTimes(1);
}
});
});

function clientRoundtrip(
Expand Down
Loading
Loading