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
24 changes: 24 additions & 0 deletions packages/functional-tests/pages/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ export abstract class BaseLayout {
});
}

/**
* Installs a page-side listener for the next `WebChannelMessageToChrome`
* event matching `command` and returns a promise that resolves with the
* event's `data` payload. Call BEFORE the action that triggers the message
* (e.g. before clicking a sign-in button) to avoid the polling/timeout race
* that `checkWebChannelMessage` is subject to on slow runners.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is our one VPN test being flaky or just noticeably slow? Reading this comment, I'm not sure when we'd use this vs not, given we have a lot of webchannel tests without it 🤔

*/
async waitForWebChannelMessage(
command: FirefoxCommand
): Promise<Record<string, unknown>> {
return this.page.evaluate(
(expected) =>
new Promise<Record<string, unknown>>((resolve) => {
window.addEventListener('WebChannelMessageToChrome', (e: Event) => {
const detail = JSON.parse((e as CustomEvent).detail);
if (detail.message.command === expected) {
resolve(detail.message.data || {});
}
});
}),
command
);
}

async checkWebChannelMessage(command: FirefoxCommand) {
// Retry across navigations — a client-side redirect after page.goto
// can destroy the execution context mid-evaluate.
Expand Down
47 changes: 46 additions & 1 deletion packages/functional-tests/tests/admin/adminPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import { expect, test } from '../../lib/fixtures/standard';

const ADMIN_PANEL_URL = process.env.ADMIN_PANEL_URL ?? 'http://localhost:8091';
const ADMIN_SERVER_URL = process.env.ADMIN_SERVER_URL ?? 'http://localhost:8095';
const ADMIN_SERVER_URL =
process.env.ADMIN_SERVER_URL ?? 'http://localhost:8095';

// Admin panel tests only run locally (stage/prod require SSO)
test.skip(({ target }) => target.name !== 'local');
Expand Down Expand Up @@ -67,4 +68,48 @@ test.describe('Admin Panel', () => {
timeout: 10000,
});
});

test('account by-uid response includes accountAuthorizations field', async ({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we wanting to expand current admin-panel functional tests? I figured we had the ones we have now as a sanity check. I'm OK with leaving but I feel like admin-panel could also mostly just be unit tests.

target,
testAccountTracker,
}) => {
const details = testAccountTracker.generateAccountDetails();
const credentials = await target.createAccount(
details.email,
details.password
);

const res = await fetch(
`${ADMIN_SERVER_URL}/api/account/by-uid?uid=${encodeURIComponent(credentials.uid)}`,
{
headers: {
'oidc-claim-id-token-email': 'test-admin@mozilla.com',
'remote-groups': 'vpn_fxa_admin_panel_prod',
},
}
);
expect(res.status).toBe(200);
const data = await res.json();
expect(Array.isArray(data.accountAuthorizations)).toBe(true);
// A brand-new account has no browser-service authorizations on record.
expect(data.accountAuthorizations).toEqual([]);
});

test('admin panel renders the authorized browser services section', async ({
target,
page,
testAccountTracker,
}) => {
const credentials = testAccountTracker.generateAccountDetails();
await target.createAccount(credentials.email, credentials.password);

await page.goto(`${ADMIN_PANEL_URL}/account-search`);
await page.getByTestId('email-input').fill(credentials.email);
await page.getByTestId('search-button').click();

await expect(
page.getByRole('heading', { name: /authorized browser services/i })
).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId('account-authorizations-none')).toBeVisible();
});
});
20 changes: 12 additions & 8 deletions packages/functional-tests/tests/misc/vpnIntegration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,21 @@ test.describe('vpn integration', () => {
await expect(signin.cachedSigninHeading).toBeVisible();
await expect(page.getByText(email)).toBeVisible();

// Install listeners before the click so the dispatched events can't be
// missed by a slow polling cycle.
const oauthLoginPromise = signin.waitForWebChannelMessage(
FirefoxCommand.OAuthLogin
);
const loginPromise = signin.waitForWebChannelMessage(FirefoxCommand.Login);

await signin.signInButton.click();

// Verify fxaOAuthLogin was sent with VPN scopes
await signin.checkWebChannelMessageScopes(
FirefoxCommand.OAuthLogin,
'https://identity.mozilla.com/apps/vpn'
const oauthLogin = await oauthLoginPromise;
expect(oauthLogin.scopes).toEqual(
expect.stringContaining('https://identity.mozilla.com/apps/vpn')
);

// Verify services data includes vpn
await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
vpn: {},
});
const login = await loginPromise;
expect(login.services).toEqual({ vpn: {} });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ let accountResponse: AccountProps = {
linkedAccounts: [],
accountEvents: [],
passkeys: [],
accountAuthorizations: [],
};

it('renders without imploding', () => {
Expand Down Expand Up @@ -319,6 +320,37 @@ it('displays the locale', async () => {
expect(getByTestId('edit-account-locale')).toBeInTheDocument();
});

it('shows "no authorizations" message when authorizations list is empty', () => {
const { getByTestId } = render(<Account {...accountResponse} />);
expect(getByTestId('account-authorizations-none')).toBeInTheDocument();
});

it('displays authorized browser services', () => {
const withAuthorizations = {
...accountResponse,
accountAuthorizations: [
{
service: 'sync',
scope: 'https://identity.mozilla.com/apps/oldsync',
authorizedAt: 1589467100316,
},
{
service: 'relay',
scope: 'https://identity.mozilla.com/apps/relay',
authorizedAt: 1589467200000,
},
],
};
const { getAllByTestId, getByRole } = render(
<Account {...withAuthorizations} />
);

expect(
getByRole('heading', { name: /authorized browser services/i })
).toBeInTheDocument();
expect(getAllByTestId('account-authorization-service')).toHaveLength(2);
});

it('displays key-stretch-version', async () => {
const lockedAccount = {
...accountResponse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { AdminPanelFeature } from '@fxa/shared/guards';
import Guard from '../../Guard';
import Subscription from '../Subscription';
import { ConnectedServices } from '../ConnectedServices';
import { AccountAuthorizations } from '../AccountAuthorizations';
import { TableRowYHeader, TableYHeaders } from '../../TableYHeaders';
import { TableRowXHeader, TableXHeaders } from '../../TableXHeaders';
import EmailBounces from '../EmailBounces';
Expand Down Expand Up @@ -94,6 +95,7 @@ export const Account = ({
backupCodes,
recoveryPhone,
passkeys,
accountAuthorizations,
}: AccountProps) => {
const createdAtDate = getFormattedDate(createdAt);
const disabledAtDate = getFormattedDate(disabledAt);
Expand Down Expand Up @@ -417,6 +419,9 @@ export const Account = ({
<Guard features={[AdminPanelFeature.ConnectedServices]}>
<h3 className="header-lg">Connected Services</h3>
<ConnectedServices services={attachedClients} />

<h3 className="header-lg">Authorized Browser Services</h3>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since signing into RPs also updates this table, I feel like we could use one sentence explaining what this is? E.g. a Relay authorization doesn't necessarily mean the user is using Relay in the browser.

We should also have a note here for support agents about "Sync" authorizations, related to the first bullet point here under caveats.

<AccountAuthorizations authorizations={accountAuthorizations} />
</Guard>

<h3 className="header-lg">Account History</h3>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { render, screen } from '@testing-library/react';
import { AccountAuthorization } from 'fxa-admin-server/src/types';
import { AccountAuthorizations } from '.';

const AUTHORIZATIONS: AccountAuthorization[] = [
{
service: 'sync',
scope: 'https://identity.mozilla.com/apps/oldsync',
authorizedAt: new Date('2026-01-01T00:00:00Z').getTime(),
},
{
service: 'relay',
scope: 'https://identity.mozilla.com/apps/relay',
authorizedAt: new Date('2026-02-01T00:00:00Z').getTime(),
},
];

it('renders the empty state when there are no authorizations', () => {
render(<AccountAuthorizations authorizations={[]} />);
expect(screen.getByTestId('account-authorizations-none')).toHaveTextContent(
'This account has not authorized any browser services.'
);
});

it('renders the empty state when authorizations is null', () => {
render(<AccountAuthorizations authorizations={null} />);
expect(screen.getByTestId('account-authorizations-none')).toBeInTheDocument();
});

it('renders one row per authorization', () => {
render(<AccountAuthorizations authorizations={AUTHORIZATIONS} />);
const services = screen.getAllByTestId('account-authorization-service');
const scopes = screen.getAllByTestId('account-authorization-scope');
const dates = screen.getAllByTestId('account-authorization-authorized-at');

expect(services).toHaveLength(2);
expect(scopes).toHaveLength(2);
expect(dates).toHaveLength(2);

expect(services[0]).toHaveTextContent('sync');
expect(scopes[0]).toHaveTextContent(
'https://identity.mozilla.com/apps/oldsync'
);
expect(services[1]).toHaveTextContent('relay');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { AccountAuthorization } from 'fxa-admin-server/src/types';
import { TableRowXHeader, TableXHeaders } from '../../TableXHeaders';
import { getFormattedDate } from '../../../lib/utils';

export const AccountAuthorizations = ({
authorizations,
}: {
authorizations?: Nullable<AccountAuthorization[]>;
}) => {
if (!authorizations || authorizations.length === 0) {
return (
<p data-testid="account-authorizations-none" className="result-none">
This account has not authorized any browser services.
</p>
);
}

return (
<TableXHeaders rowHeaders={['Service', 'Scope', 'Authorized At']}>
{authorizations.map(({ service, scope, authorizedAt }) => (
<TableRowXHeader key={`${service}-${scope}`}>
<td data-testid="account-authorization-service">{service}</td>
<td data-testid="account-authorization-scope">{scope}</td>
<td data-testid="account-authorization-authorized-at">
{getFormattedDate(authorizedAt)}
</td>
</TableRowXHeader>
))}
</TableXHeaders>
);
};
54 changes: 54 additions & 0 deletions packages/fxa-admin-server/src/database/database.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ describe('#integration - DatabaseService', () => {

beforeAll(async () => {
knex = await testDatabaseSetup();
// Ensure the table exists in the test DB. The shared oauth fixture set
// is built once and cached; adding a new fixture file there can be
// skipped by stale build caches, so create the table inline here.
await knex.raw(
'CREATE TABLE IF NOT EXISTS `accountAuthorizations` (' +
'`uid` BINARY(16) NOT NULL,' +
'`scope` VARCHAR(512) NOT NULL,' +
'`service` VARCHAR(64) NOT NULL,' +
'`authorizedAt` BIGINT UNSIGNED NOT NULL,' +
'PRIMARY KEY (`uid`, `scope`, `service`)' +
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
});

afterAll(async () => {
Expand Down Expand Up @@ -89,4 +101,46 @@ describe('#integration - DatabaseService', () => {
it('should be able to invoke attachedDevices', async () => {
await service.attachedDevices('AB12');
});

it('returns rows ordered by authorizedAt descending', async () => {
const uid = 'aabbccddeeff00112233445566778899';
const uidBuffer = Buffer.from(uid, 'hex');
const now = Date.now();
await service.knexOauth('accountAuthorizations').insert([
{
uid: uidBuffer,
scope: 'https://identity.mozilla.com/apps/oldsync',
service: 'sync',
authorizedAt: now - 1000,
},
{
uid: uidBuffer,
scope: 'https://identity.mozilla.com/apps/relay',
service: 'relay',
authorizedAt: now,
},
]);

const rows = await service.accountAuthorizations(uid);

expect(rows).toEqual([
{
scope: 'https://identity.mozilla.com/apps/relay',
service: 'relay',
authorizedAt: now,
},
{
scope: 'https://identity.mozilla.com/apps/oldsync',
service: 'sync',
authorizedAt: now - 1000,
},
]);
});

it('returns an empty array when the uid has no authorizations', async () => {
const rows = await service.accountAuthorizations(
'00000000000000000000000000000001'
);
expect(rows).toEqual([]);
});
});
18 changes: 18 additions & 0 deletions packages/fxa-admin-server/src/database/database.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { MozLoggerService } from '@fxa/shared/mozlog';
import { StatsD } from 'hot-shots';
import { Knex, knex } from 'knex';
import { AppConfig } from '../config';
import { AccountAuthorization } from '../types';
import { uuidTransformer } from './transformers';

function typeCasting(field: any, next: any) {
if (field.type === 'TINY' && field.length === 1) {
Expand Down Expand Up @@ -137,6 +139,22 @@ export class DatabaseService implements OnModuleDestroy {
return mergeCachedSessionTokens(dbSessionTokens, cachedSessionTokens, true);
}

public async accountAuthorizations(
uid: string
): Promise<AccountAuthorization[]> {
const uidBuffer = uuidTransformer.to(uid);
const rows = await this.knexOauth('accountAuthorizations')
.select('scope', 'service', 'authorizedAt')
.where('uid', uidBuffer)
.orderBy('authorizedAt', 'desc')
.limit(10);
return rows.map((row) => ({
scope: row.scope,
service: row.service,
authorizedAt: Number(row.authorizedAt),
}));
}

public async attachedDevices(uid: string) {
const [devices, sessionTokens] = await Promise.all([
this.device.findByUid(uid),
Expand Down
Loading
Loading