Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion portals/admin/src/main/webapp/admin/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
<link href="/site/public/css/main.css" type="text/css" rel="stylesheet" />
<link href="/site/public/css/draftjs.css" type="text/css" rel="stylesheet" />
<link rel="shortcut icon" href="/site/public/images/favicon.png">
</head>
<link rel="icon" type="image/png" href="/site/public/images/_favicon.png">
</head>

<body>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<link href="<%= context%>/site/public/css/main.css" type="text/css" rel="stylesheet" />
<link href="<%= context%>/site/public/css/draftjs.css" type="text/css" rel="stylesheet" />
<link rel="shortcut icon" href="<%= context%>/site/public/images/favicon.png">
<link rel="icon" type="image/png" href="<%= context%>/site/public/images/_favicon.png">
</head>

<body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function Copyright() {
<Typography variant='body2' color='textSecondary' align='center' sx={{ p: '16px' }}>
<FormattedMessage
id='Base.Footer.Footer.product_details'
defaultMessage='WSO2 API-M v4.7.0 | © 2025 WSO2 LLC'
defaultMessage='WSO2 API-M v4.7.0 | © 2026 WSO2 LLC'
/>
</Typography>
);
Expand Down
1 change: 1 addition & 0 deletions portals/publisher/src/main/webapp/publisher/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<link href="/site/public/css/main.css" rel="stylesheet" />
<link href="/site/public/css/draftjs.css" rel="stylesheet" />
<link rel="shortcut icon" href="/site/public/images/favicon.png">
<link rel="icon" type="image/png" href="/site/public/images/_favicon.png">
</head>

<body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<title></title>

<link rel="shortcut icon" href="<%= context%>/site/public/images/favicon.png">
<link rel="icon" type="image/png" href="<%= context%>/site/public/images/_favicon.png">
<link href="<%= context%>/site/public/css/main.css" type="text/css" rel="stylesheet" />
<link href="<%= context%>/site/public/css/draftjs.css" type="text/css" rel="stylesheet" />
</head>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved.
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/**
* Regression test for issue #4972 bug 2.
*
* The Admin portal's Copyright component was not rolled forward to 2026 when
* Publisher and DevPortal were updated, so the rendered footer showed
* "© 2025 WSO2 LLC". This test scans the defaultMessage string for the
* Base.Footer.Footer.product_details intl key in each portal's source and
* asserts it carries the current (expected) year. The expected year is
* derived from the Publisher's Footer.jsx — the portal that was correct
* at the time the bug was reported — so the test auto-updates when the
* year is rolled forward next time, as long as Publisher is rolled first.
*/
const fs = require('fs');

Check warning on line 31 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/copyrightYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tz2_y-97L9why848&open=AZ20tz2_y-97L9why848&pullRequest=1332
const path = require('path');

Check warning on line 32 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/copyrightYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tz2_y-97L9why849&open=AZ20tz2_y-97L9why849&pullRequest=1332

const portalsRoot = path.resolve(__dirname, '../../../../../../../..');

const PRODUCT_DETAILS_LINE = /defaultMessage=['"]([^'"]*WSO2 API-M[^'"]*WSO2 LLC)['"]/;

function readProductDetailsDefault(file) {
const src = fs.readFileSync(file, 'utf8');
const m = src.match(PRODUCT_DETAILS_LINE);
if (!m) {
throw new Error(`Could not find product_details defaultMessage in ${file}`);
}
return m[1];
}

const sources = {
publisher: path.join(
portalsRoot,
'publisher/src/main/webapp/source/src/app/components/Base/Footer/Footer.jsx',
),
admin: path.join(
portalsRoot,
'admin/src/main/webapp/source/src/app/components/Base/index.jsx',
),
devportal: path.join(
portalsRoot,
'devportal/src/main/webapp/source/src/app/components/Base/index.jsx',
),
};

describe('Issue #4972 — portal Copyright defaultMessage carries the expected year', () => {
const publisherMessage = readProductDetailsDefault(sources.publisher);
const yearMatch = publisherMessage.match(/©\s*(\d{4})\s*WSO2 LLC/);
const expectedYear = yearMatch && yearMatch[1];

Check warning on line 65 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/copyrightYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tz2_y-97L9why84-&open=AZ20tz2_y-97L9why84-&pullRequest=1332

it('publisher Footer has a parseable © <year> WSO2 LLC marker', () => {
expect(expectedYear).toMatch(/^\d{4}$/);
});

it('expected year is 2026 or later (bug was: stale 2025)', () => {
expect(Number(expectedYear)).toBeGreaterThanOrEqual(2026);
});

it.each([
['admin', sources.admin],
['devportal', sources.devportal],
])('%s Copyright defaultMessage carries the same year as publisher', (portal, file) => {
const msg = readProductDetailsDefault(file);
expect(msg).toContain(`© ${expectedYear} WSO2 LLC`);
expect(msg).not.toMatch(/©\s*2025\s*WSO2 LLC/);
});

it('admin Copyright defaultMessage matches the expected product detail line exactly', () => {
// Catches e.g. a typo in the product name or a reverted version bump.
const msg = readProductDetailsDefault(sources.admin);
expect(msg).toBe(`WSO2 API-M v4.7.0 | © ${expectedYear} WSO2 LLC`);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved.
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/**
* Regression test for issue #4972 bug 1.
*
* The Publisher and Admin portal HTML entry points were missing the modern
* <link rel="icon" type="image/png"> tag that the DevPortal already emitted.
* Chromium therefore never requested a favicon for Publisher/Admin and the
* tab icon was blank. The fix adds the tag alongside the legacy
* <link rel="shortcut icon"> in every portal entry template.
*
* This test reads the four Publisher + Admin templates straight from source
* and asserts that both link tags are present. The DevPortal baselines are
* asserted too so the test also catches any regression there.
*/
const fs = require('fs');

Check warning on line 32 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/favicon.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tz24y-97L9why846&open=AZ20tz24y-97L9why846&pullRequest=1332
const path = require('path');

Check warning on line 33 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/favicon.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tz24y-97L9why847&open=AZ20tz24y-97L9why847&pullRequest=1332

const portalsRoot = path.resolve(__dirname, '../../../../../../../..');

const templates = [
{
portal: 'publisher',
file: path.join(portalsRoot, 'publisher/src/main/webapp/site/public/pages/index.jsp.hbs'),
expectedHref: '/site/public/images/_favicon.png',
},
{
portal: 'publisher',
file: path.join(portalsRoot, 'publisher/src/main/webapp/publisher/index.html'),
expectedHref: '/site/public/images/_favicon.png',
},
{
portal: 'admin',
file: path.join(portalsRoot, 'admin/src/main/webapp/site/public/pages/index.jsp.hbs'),
expectedHref: '/site/public/images/_favicon.png',
},
{
portal: 'admin',
file: path.join(portalsRoot, 'admin/src/main/webapp/admin/index.ejs'),
expectedHref: '/site/public/images/_favicon.png',
},
{
portal: 'devportal',
file: path.join(portalsRoot, 'devportal/src/main/webapp/site/public/pages/index.jsp.hbs'),
expectedHref: '/site/public/images/_favicon.png',
},
{
portal: 'devportal',
file: path.join(portalsRoot, 'devportal/src/main/webapp/devportal/index.ejs'),
expectedHref: '/site/public/images/_favicon.png',
},
];

describe('Issue #4972 — portal HTML templates emit both favicon link tags', () => {
templates.forEach(({ portal, file, expectedHref }) => {
describe(`${portal}: ${path.basename(file)}`, () => {
let markup;
beforeAll(() => {
markup = fs.readFileSync(file, 'utf8');
});

it('emits the legacy <link rel="shortcut icon"> tag', () => {
expect(markup).toMatch(/<link[^>]+rel=["']shortcut icon["'][^>]*>/);
});

it('emits the modern <link rel="icon" type="image/png"> tag', () => {
expect(markup).toMatch(/rel=["']icon["']\s+type=["']image\/png["']/);
});

it('points the modern <link rel="icon"> at _favicon.png', () => {
// The surrounding <link ...> may embed templating tokens like
// <%= context%> so we don't try to match the whole tag — we
// just assert _favicon.png is referenced in the file alongside
// a rel="icon" tag (asserted separately above).
expect(markup).toContain(expectedHref);
Comment on lines +82 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tie _favicon.png to the modern favicon link.

The current assertions can pass if _favicon.png appears elsewhere while the modern <link rel="icon"> points to a different asset. Match a single link tag containing rel, type, and the expected href.

Proposed test tightening
-            it('emits the modern <link rel="icon" type="image/png"> tag', () => {
-                expect(markup).toMatch(/rel=["']icon["']\s+type=["']image\/png["']/);
-            });
-
-            it('points the modern <link rel="icon"> at _favicon.png', () => {
-                // The surrounding <link ...> may embed templating tokens like
-                // <%= context%> so we don't try to match the whole tag — we
-                // just assert _favicon.png is referenced in the file alongside
-                // a rel="icon" tag (asserted separately above).
-                expect(markup).toContain(expectedHref);
+            it('emits the modern <link rel="icon" type="image/png"> tag pointing at _favicon.png', () => {
+                const escapedHref = expectedHref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+                expect(markup).toMatch(new RegExp(
+                    `<link\\b(?=[^>]*\\brel=["']icon["'])(?=[^>]*\\btype=["']image/png["'])`
+                    + `(?=[^>]*\\bhref=["'][^"']*${escapedHref}["'])[^>]*>`,
+                ));
             });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/favicon.test.js`
around lines 82 - 91, The two tests ("emits the modern <link rel=\"icon\"
type=\"image/png\"> tag" and "points the modern <link rel=\"icon\"> at
_favicon.png") are too loose because they allow `_favicon.png` to appear
anywhere; tighten the second assertion to match a single <link ...> tag that
contains rel="icon", type="image/png", and the expectedHref together. Replace
the toContain(expectedHref) check on markup with a toMatch using a RegExp that
looks for a single <link[^>]* rel=["']icon["'][^>]* type=["']image\/png["'][^>]*
href=["']<expectedHref>["'][^>]*>, and if expectedHref may contain
regex-significant chars create/use an escapeRegExp utility to escape
expectedHref before interpolating into the RegExp.

});
});
});

it('asserts the _favicon.png asset exists in every portal public/images directory', () => {
const assetPaths = [
path.join(portalsRoot, 'publisher/src/main/webapp/site/public/images/_favicon.png'),
path.join(portalsRoot, 'admin/src/main/webapp/site/public/images/_favicon.png'),
path.join(portalsRoot, 'devportal/src/main/webapp/site/public/images/_favicon.png'),
];
assetPaths.forEach((p) => {
expect(fs.existsSync(p)).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com) All Rights Reserved.
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/**
* Regression test for issue #4972 bug 2 — locale files.
*
* Beyond the JSX defaultMessage, the admin en.json and fr.json carried a
* stale "© 2025 WSO2 LLC" at locales/en.json:409 / fr.json in the shipped
* pack. That string is what users actually see, so we scan every locale
* file in every portal's webapp and assert any translation of
* Base.Footer.Footer.product_details either matches the current expected
* year or is an intentionally-blank fallback (which react-intl falls back
* to the defaultMessage for).
*/
const fs = require('fs');

Check warning on line 30 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/localeYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tzx_y-97L9why841&open=AZ20tzx_y-97L9why841&pullRequest=1332
const path = require('path');

Check warning on line 31 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/localeYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tzx_y-97L9why842&open=AZ20tzx_y-97L9why842&pullRequest=1332

const portalsRoot = path.resolve(__dirname, '../../../../../../../..');
const PRODUCT_DETAILS_KEY = 'Base.Footer.Footer.product_details';

const PRODUCT_DETAILS_LINE = /defaultMessage=['"]([^'"]*WSO2 API-M[^'"]*WSO2 LLC)['"]/;

function readProductDetailsDefault(file) {
const src = fs.readFileSync(file, 'utf8');
const m = src.match(PRODUCT_DETAILS_LINE);
return m && m[1];

Check warning on line 41 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/localeYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tzx_y-97L9why843&open=AZ20tzx_y-97L9why843&pullRequest=1332
}

function expectedYear() {
const publisherMsg = readProductDetailsDefault(
path.join(
portalsRoot,
'publisher/src/main/webapp/source/src/app/components/Base/Footer/Footer.jsx',
),
);
const match = publisherMsg && publisherMsg.match(/©\s*(\d{4})\s*WSO2 LLC/);

Check warning on line 51 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/localeYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tzx_y-97L9why844&open=AZ20tzx_y-97L9why844&pullRequest=1332
return match && match[1];

Check warning on line 52 in portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/localeYear.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ20tzx_y-97L9why845&open=AZ20tzx_y-97L9why845&pullRequest=1332
}

function collectLocaleFiles() {
const portals = ['publisher', 'admin', 'devportal'];
const out = [];
portals.forEach((portal) => {
const dir = path.join(portalsRoot, `${portal}/src/main/webapp/site/public/locales`);
if (!fs.existsSync(dir)) return;
fs.readdirSync(dir).forEach((f) => {
if (f.endsWith('.json')) {
out.push({ portal, locale: f, file: path.join(dir, f) });
}
});
});
return out;
}

describe('Issue #4972 — locale files for Base.Footer.Footer.product_details', () => {
const year = expectedYear();
const localeFiles = collectLocaleFiles();

it('the publisher defaultMessage exposes an expected year', () => {
expect(year).toMatch(/^\d{4}$/);
expect(Number(year)).toBeGreaterThanOrEqual(2026);
});

it('locale directories were discovered', () => {
expect(localeFiles.length).toBeGreaterThan(0);
Comment on lines +55 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fail when an expected portal locale directory is missing.

Line 60 silently skips missing portal locale dirs, and Line 80 only proves that at least one locale file exists. That can drop admin/devportal coverage without failing this regression test.

Proposed test hardening
+const PORTALS = ['publisher', 'admin', 'devportal'];
+
 function collectLocaleFiles() {
-    const portals = ['publisher', 'admin', 'devportal'];
     const out = [];
-    portals.forEach((portal) => {
+    PORTALS.forEach((portal) => {
         const dir = path.join(portalsRoot, `${portal}/src/main/webapp/site/public/locales`);
-        if (!fs.existsSync(dir)) return;
+        if (!fs.existsSync(dir)) {
+            throw new Error(`Missing locale directory for ${portal}: ${dir}`);
+        }
         fs.readdirSync(dir).forEach((f) => {
             if (f.endsWith('.json')) {
                 out.push({ portal, locale: f, file: path.join(dir, f) });
             }
         });
@@
     it('locale directories were discovered', () => {
         expect(localeFiles.length).toBeGreaterThan(0);
+        expect(new Set(localeFiles.map(({ portal }) => portal))).toEqual(new Set(PORTALS));
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function collectLocaleFiles() {
const portals = ['publisher', 'admin', 'devportal'];
const out = [];
portals.forEach((portal) => {
const dir = path.join(portalsRoot, `${portal}/src/main/webapp/site/public/locales`);
if (!fs.existsSync(dir)) return;
fs.readdirSync(dir).forEach((f) => {
if (f.endsWith('.json')) {
out.push({ portal, locale: f, file: path.join(dir, f) });
}
});
});
return out;
}
describe('Issue #4972 — locale files for Base.Footer.Footer.product_details', () => {
const year = expectedYear();
const localeFiles = collectLocaleFiles();
it('the publisher defaultMessage exposes an expected year', () => {
expect(year).toMatch(/^\d{4}$/);
expect(Number(year)).toBeGreaterThanOrEqual(2026);
});
it('locale directories were discovered', () => {
expect(localeFiles.length).toBeGreaterThan(0);
const PORTALS = ['publisher', 'admin', 'devportal'];
function collectLocaleFiles() {
const out = [];
PORTALS.forEach((portal) => {
const dir = path.join(portalsRoot, `${portal}/src/main/webapp/site/public/locales`);
if (!fs.existsSync(dir)) {
throw new Error(`Missing locale directory for ${portal}: ${dir}`);
}
fs.readdirSync(dir).forEach((f) => {
if (f.endsWith('.json')) {
out.push({ portal, locale: f, file: path.join(dir, f) });
}
});
});
return out;
}
describe('Issue `#4972` — locale files for Base.Footer.Footer.product_details', () => {
const year = expectedYear();
const localeFiles = collectLocaleFiles();
it('the publisher defaultMessage exposes an expected year', () => {
expect(year).toMatch(/^\d{4}$/);
expect(Number(year)).toBeGreaterThanOrEqual(2026);
});
it('locale directories were discovered', () => {
expect(localeFiles.length).toBeGreaterThan(0);
expect(new Set(localeFiles.map(({ portal }) => portal))).toEqual(new Set(PORTALS));
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@portals/publisher/src/main/webapp/source/Tests/Unit/Issue4972/localeYear.test.js`
around lines 55 - 80, The test silently skips missing portal locale directories
in collectLocaleFiles (portals array in collectLocaleFiles) which can hide
missing admin/devportal coverage; update the test or collectLocaleFiles so it
asserts per-portal presence: for each portal in portals (publisher, admin,
devportal) check the directory at path.join(portalsRoot,
`${portal}/src/main/webapp/site/public/locales`) exists and contains at least
one .json, and fail the test if any portal directory is missing or empty
(reference collectLocaleFiles, portals array, and localeFiles variable to
implement this per-portal existence/coverage check).

});

describe.each(localeFiles)('$portal/locales/$locale', ({ file }) => {
let bundle;
beforeAll(() => {
bundle = JSON.parse(fs.readFileSync(file, 'utf8'));
});

it('if product_details is present, it is non-null and a string', () => {
if (PRODUCT_DETAILS_KEY in bundle) {
expect(typeof bundle[PRODUCT_DETAILS_KEY]).toBe('string');
}
});

it('if product_details has a translation, it carries the current year', () => {
const value = bundle[PRODUCT_DETAILS_KEY];
// Empty string is allowed — react-intl falls back to defaultMessage.
if (value && value.trim() !== '') {
expect(value).toContain(`© ${year} WSO2 LLC`);
expect(value).not.toMatch(/©\s*2025\s*WSO2 LLC/);
}
});
});
});
Loading
Loading