diff --git a/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations-test-cases.md b/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations-test-cases.md new file mode 100644 index 00000000000..d04cf4579d0 --- /dev/null +++ b/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations-test-cases.md @@ -0,0 +1,199 @@ +# Backfill `accountAuthorizations` — Test Cases + +Script: `packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations.ts` + +The matching logic mirrors `lib/oauth/browser-services.ts:recordAuthorizationOnLogin`: + +- **Scope match** requires the clientId to be a recognized minter for that service (scope alone is not proof of authorization). +- **ClientId-only fallback** only fires when the clientId maps to **exactly one** service. +- **Service-ambiguous clientIds** (Firefox Desktop, `5882386c6d801776`) cannot be reliably attributed without `serviceParam`, which is not stored on `refreshTokens`. These are skipped unless scope+clientId match resolves a single service. + +--- + +## Test Cases + +| ID | Category | Setup | Expected Outcome | +| --- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T01 | Basic match | 1 token, relay scope + relay clientId (scope+clientId AND match) | 1 row: `(uid, relay-scope, 'relay', token.createdAt)` | +| T02 | Basic match | 1 token, vpn scope + vpn clientId | 1 row for `'vpn'` | +| T03 | Basic match | 1 token, relay clientId (single-service), no relay scope | 1 row for `'relay'` — matched via clientId-only fallback | +| T04 | Basic match | 1 token, `profile` scope only, unrelated clientId | 0 rows — no service match | +| T05 | Basic match | 1 token, `openid` scope only, unrelated clientId | 0 rows | +| T06 | Basic match | 1 token, `profile openid` multi-scope, unrelated clientId | 0 rows | +| T07 | Multi-service | 1 token, relay+vpn scopes, clientId that mints both | 2 rows — one per service, same uid, same `authorizedAt` | +| T08 | Multi-service | 1 token, relay+`profile` scope, relay clientId | 1 row for relay only | +| T09 | Multi-token, same service | 2 tokens, same uid, both relay scope + relay clientId; token A `createdAt` earlier than B | 1 row: `authorizedAt = A.createdAt` (LEAST) | +| T10 | Multi-token, same service | 2 tokens, same uid, both relay scope + relay clientId, identical timestamps | 1 row, no duplication | +| T11 | Multi-token, same service | 3 tokens, same uid, relay scope + relay clientId, `createdAt` interleaved across all three | 1 row: `authorizedAt = min(createdAt)` across all three | +| T12 | Multi-token, multi-service | Token A: relay scope + relay clientId. Token B: vpn scope + vpn clientId. Same uid. | 2 rows — one relay, one vpn | +| T13 | Multi-token, multi-service | Token A: relay scope + relay clientId. Token B: `profile` scope + unrelated clientId. Same uid. | 1 row for relay only | +| T14 | Scope matching | Scope = `https://identity.mozilla.com/apps/relay-extra`, unrelated clientId | 0 rows — partial match must not fire | +| T15 | Scope matching | Scope = `https://identity.mozilla.com/apps/rela`, unrelated clientId | 0 rows | +| T16 | Scope matching | Scope = `profile https://identity.mozilla.com/apps/relay`, relay clientId | 1 row — correctly splits space-separated string and matches relay | +| T17 | Timestamps | 1 token, relay scope + relay clientId, specific `createdAt` (e.g. `2025-01-15T10:00:00Z`) | `authorizedAt = 1736935200000` (verifies TIMESTAMP → BIGINT ms-epoch conversion) | +| T18 | Idempotency | Pre-existing row with `authorizedAt = T`. Token has same `createdAt = T`. | Row unchanged (LEAST(T, T) = T) — no-op | +| T19 | Idempotency | Pre-existing row with `authorizedAt = T`. Token has `createdAt = T+N` (later). | Row unchanged: `authorizedAt` stays at `T` (LEAST wins, existing is earlier) | +| T20 | Idempotency | Pre-existing row with `authorizedAt = T`. Token has `createdAt = T-N` (earlier). | Row updated: `authorizedAt = T-N` (LEAST wins, token reveals an earlier authorization) | +| T21 | Idempotency | Script run twice on identical data | Result identical to single run | +| T22 | Strict matching | 1 token, relay scope, clientId that is **not** a relay minter | 0 rows — scope alone is not proof of authorization (matches `recordAuthorizationOnLogin`) | +| T23 | Strict matching | 1 token, no service scope, clientId that maps to **multiple** services (e.g. mobile Firefox in relay+vpn+sync) | 0 rows — multi-service clientId without scope is ambiguous, no fallback | +| T24 | Strict matching | 1 token, no service scope, Firefox Desktop clientId (`5882386c6d801776`) | 0 rows — service-ambiguous clientId requires `serviceParam` which isn't stored on refreshTokens; metric `tokens_skipped{reason=ambiguous_no_match}` increments | +| T25 | Strict matching | 1 token, relay scope, Firefox Desktop clientId (which is in relay's `clientIds`) | 1 row — scope+clientId AND still works for ambiguous clientIds | +| T26 | Dry run | Various tokens covering T01–T08 cases, run with `--dry-run` | 0 rows written. Log reports correct per-service counts. Statsd metrics still emitted. | +| T27 | Dry run | Pre-existing rows in `accountAuthorizations`, run with `--dry-run` | Table unchanged. Counts logged correctly. | +| T28 | Resumability | 500 tokens, `--batch-size 100`. Interrupt after batch 2 (cursor at position 200). Restart with `--start-cursor `. | Final table state identical to an uninterrupted run. | +| T29 | Volume/batching | 10,000 tokens, mix of service and non-service scopes, `--batch-size 500` | All service-matching tokens produce rows. Cursor advances correctly across all batches. No rows missed or duplicated. | +| T30 | Volume/batching | 10,000 tokens, all non-service scopes + unrelated clientIds | 0 rows in `accountAuthorizations`. Script completes cleanly. | +| T31 | Service filter | Tokens for relay + vpn + sync. Run with `--service relay`. | Only relay rows written. VPN and sync tokens scanned but not upserted. | +| T32 | Service filter | 1 token matching both relay (scope) and vpn (scope) from a clientId minting both. Run with `--service relay`. | 1 row for relay only — vpn skipped at output filter. | +| T33 | Service filter | Run unfiltered, then run `--service relay` again. | Relay rows: `authorizedAt` unchanged (LEAST is a no-op for identical values). Other services unaffected. | +| T34 | Service filter | `--service nonexistent` | Script exits early with clear error: `No service 'nonexistent' found in browserServices config`. | + +--- + +## Seeding Strategy + +### Layer 1 — Exact test case rows + +Small, deterministic rows with controlled UIDs, client IDs, scopes, and timestamps — one or a few rows per case. These are asserted against field-by-field after the run. + +### Layer 2 — Volume noise + +N thousand rows with random UIDs, random non-service client IDs, and non-service scopes (`profile`, `openid`, `https://example.com/other`). Should produce 0 rows in `accountAuthorizations` but force real batching behaviour across at least 20–30 batches at the chosen batch size. + +Seeding should be deterministic (fixed values or fixed random seed) so expected outcomes are reproducible across runs. + +--- + +## Coverage Notes + +- **T01–T21** are covered by the deterministic seed in `seed-test-data.ts` and verified by the SQL block at the bottom of this doc. +- **T22–T25 (strict matching edge cases)** are documented but not yet in the seed. To verify, add tokens with: + - T22: relay scope + an unrelated clientId (uid `aa…0022`) + - T23: profile scope + a multi-service clientId like mobile Firefox `1b1a3e44c54fbb58` (uid `aa…0023`) + - T24: profile scope + Firefox Desktop `5882386c6d801776` (uid `aa…0024`) + - T25: relay scope + Firefox Desktop `5882386c6d801776` (uid `aa…0025`) +- **T26–T34** are run-mode tests (dry run, resumability, volume, service filter) verified manually via flags and log inspection. + +## Open Questions + +- **Sync desktop (`allowSilentExchange: false`)**: Should Sync **desktop** refresh tokens be backfilled? The flag governs future silent exchanges, not historical state. Mobile Firefox already mints sync via refresh tokens; desktop is the open question. See the related caveat in the [PR #20500 review](https://github.com/mozilla/fxa/pull/20500#pullrequestreview-4238172630). If excluded, possibly add `backfill: false` to the service config, or omit from backfill manually (rp by rp runs). Needs confirmation before implementation. + +## Verifying Results + +Once the seed data is added, and backfill is run, we can use the following to verify the results + +```sql +SELECT test_id, HEX(uid) AS uid, service, actual_rows, expected_rows, + authorizedAt, expected_authorizedAt, + IF( + actual_rows = expected_rows + AND (expected_authorizedAt IS NULL OR authorizedAt = expected_authorizedAt), + 'PASS', 'FAIL' + ) AS result +FROM ( + SELECT 'T01' AS test_id, x'aa000000000000000000000000000001' AS uid, 'relay' AS service, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000001' AND service = 'relay') AS actual_rows, + 1 AS expected_rows, + (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000001' AND service = 'relay' LIMIT 1) AS authorizedAt, + NULL AS expected_authorizedAt + + UNION ALL SELECT 'T02', x'aa000000000000000000000000000002', 'vpn', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000002' AND service = 'vpn'), + 1, (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000002' AND service = 'vpn' LIMIT 1), NULL + + UNION ALL SELECT 'T03 (clientId-only fallback)', x'aa000000000000000000000000000003', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000003' AND service = 'relay'), + 1, NULL, NULL + + UNION ALL SELECT 'T04 (expect 0)', x'aa000000000000000000000000000004', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000004'), + 0, NULL, NULL + + UNION ALL SELECT 'T05 (expect 0)', x'aa000000000000000000000000000005', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000005'), + 0, NULL, NULL + + UNION ALL SELECT 'T06 (expect 0)', x'aa000000000000000000000000000006', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000006'), + 0, NULL, NULL + + UNION ALL SELECT 'T07 relay+vpn (expect 2 rows)', x'aa000000000000000000000000000007', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000007'), + 2, NULL, NULL + + UNION ALL SELECT 'T08', x'aa000000000000000000000000000008', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000008' AND service = 'relay'), + 1, NULL, NULL + + UNION ALL SELECT 'T09 authorizedAt=LEAST', x'aa000000000000000000000000000009', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000009' AND service = 'relay'), + 1, + (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000009' AND service = 'relay' LIMIT 1), + 1704067200000 + + UNION ALL SELECT 'T10', x'aa00000000000000000000000000000a', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa00000000000000000000000000000a' AND service = 'relay'), + 1, NULL, NULL + + UNION ALL SELECT 'T11 authorizedAt=LEAST(3 tokens)', x'aa00000000000000000000000000000b', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa00000000000000000000000000000b' AND service = 'relay'), + 1, + (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa00000000000000000000000000000b' AND service = 'relay' LIMIT 1), + 1704067200000 + + UNION ALL SELECT 'T12 relay+vpn (expect 2 rows)', x'aa00000000000000000000000000000c', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa00000000000000000000000000000c'), + 2, NULL, NULL + + UNION ALL SELECT 'T13', x'aa00000000000000000000000000000d', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa00000000000000000000000000000d' AND service = 'relay'), + 1, NULL, NULL + + UNION ALL SELECT 'T14 (expect 0)', x'aa00000000000000000000000000000e', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa00000000000000000000000000000e'), + 0, NULL, NULL + + UNION ALL SELECT 'T15 (expect 0)', x'aa00000000000000000000000000000f', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa00000000000000000000000000000f'), + 0, NULL, NULL + + UNION ALL SELECT 'T16', x'aa000000000000000000000000000010', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000010' AND service = 'relay'), + 1, NULL, NULL + + UNION ALL SELECT 'T17 authorizedAt=BIGINT', x'aa000000000000000000000000000011', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000011' AND service = 'relay'), + 1, + (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000011' AND service = 'relay' LIMIT 1), + 1736935200000 + + UNION ALL SELECT 'T18 idempotent no-op', x'aa000000000000000000000000000012', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000012' AND service = 'relay'), + 1, + (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000012' AND service = 'relay' LIMIT 1), + 1704067200000 + + UNION ALL SELECT 'T19 idempotent no-op', x'aa000000000000000000000000000013', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000013' AND service = 'relay'), + 1, + (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000013' AND service = 'relay' LIMIT 1), + 1704067200000 + + UNION ALL SELECT 'T20 authorizedAt=LEAST(pre-seed)', x'aa000000000000000000000000000014', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000014' AND service = 'relay'), + 1, + (SELECT authorizedAt FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000014' AND service = 'relay' LIMIT 1), + 1685577600000 + + UNION ALL SELECT 'T21 second run no-op', x'aa000000000000000000000000000015', 'relay', + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000015' AND service = 'relay'), + 1, NULL, NULL + + UNION ALL SELECT 'noise-client (expect 0)', x'aa000000000000000000000000000099', NULL, + (SELECT COUNT(*) FROM accountAuthorizations WHERE uid = x'aa000000000000000000000000000099'), + 0, NULL, NULL + +) AS checks +ORDER BY test_id; +``` diff --git a/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations.spec.ts b/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations.spec.ts new file mode 100644 index 00000000000..9978f974e07 --- /dev/null +++ b/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations.spec.ts @@ -0,0 +1,849 @@ +/* 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 * as fs from 'fs'; + +jest.mock('fs'); + +import { + BrowserServiceConfig, + ResolvedService, + ServiceIndexes, + UpsertRow, + resolveServices, + buildResolvedService, + buildServiceIndexes, + findMatchingServices, + workerCursors, + batchUpsert, + writeCheckpoint, + fetchBatch, + estimateTotalTokens, + parseCursorHex, + withRetry, + run, +} from './backfill-account-authorizations'; +import { SERVICE_AMBIGUOUS_CLIENT_IDS } from '../../lib/oauth/browser-services'; + +const RELAY_SCOPE = 'https://identity.mozilla.com/apps/relay'; +const VPN_SCOPE = 'https://identity.mozilla.com/apps/vpn'; +const RELAY_CLIENT_ID = 'aabbccdd11223344'; +const VPN_CLIENT_ID = 'deadbeef12345678'; + +function makeServiceConfig( + overrides: Partial = {} +): BrowserServiceConfig { + return { + displayName: 'Test Service', + authorizationScope: RELAY_SCOPE, + clientIds: [RELAY_CLIENT_ID], + serviceParams: [], + retentionDays: 365, + allowSilentExchange: false, + ...overrides, + }; +} + +function makeLog() { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }; +} + +function makeQuery(results: any[] = []) { + return jest.fn().mockResolvedValue(results); +} + +describe('resolveServices', () => { + const rawConfig = { + relay: makeServiceConfig({ + authorizationScope: RELAY_SCOPE, + clientIds: [RELAY_CLIENT_ID], + }), + vpn: makeServiceConfig({ + displayName: 'VPN', + authorizationScope: VPN_SCOPE, + clientIds: [VPN_CLIENT_ID], + }), + }; + + it('returns all services from the config', () => { + const services = resolveServices(rawConfig); + expect(services).toHaveLength(2); + expect(services.map((s) => s.name)).toEqual(['relay', 'vpn']); + }); + + it('throws when the config has no services', () => { + expect(() => resolveServices({})).toThrow( + 'browserServices config is empty' + ); + }); +}); + +describe('buildResolvedService', () => { + it('maps name and authorizationScope from config', () => { + const svc = buildResolvedService('relay', makeServiceConfig()); + expect(svc.name).toBe('relay'); + expect(svc.authorizationScope).toBe(RELAY_SCOPE); + }); + + it('lowercases all clientIds for case-insensitive lookup', () => { + const svc = buildResolvedService( + 'relay', + makeServiceConfig({ clientIds: ['AABBCCDD11223344', 'DeAdBeEf'] }) + ); + expect(svc.clientIdSet.has('aabbccdd11223344')).toBe(true); + expect(svc.clientIdSet.has('deadbeef')).toBe(true); + expect(svc.clientIdSet.has('AABBCCDD11223344')).toBe(false); + }); + + it('handles an empty clientIds array', () => { + const svc = buildResolvedService( + 'relay', + makeServiceConfig({ clientIds: [] }) + ); + expect(svc.clientIdSet.size).toBe(0); + }); + + it('handles a missing clientIds field gracefully', () => { + const cfg = makeServiceConfig(); + delete (cfg as any).clientIds; + const svc = buildResolvedService('relay', cfg); + expect(svc.clientIdSet.size).toBe(0); + }); +}); + +describe('findMatchingServices', () => { + // Pull the ambiguous clientId from the canonical source so this drifts when + // the runtime set changes. + const FIREFOX_DESKTOP_CLIENT_ID = [...SERVICE_AMBIGUOUS_CLIENT_IDS][0]; + const SHARED_CLIENT_ID = 'cafef00d11223344'; // present in both relay AND vpn + + const relayService: ResolvedService = { + name: 'relay', + authorizationScope: RELAY_SCOPE, + clientIdSet: new Set([RELAY_CLIENT_ID, SHARED_CLIENT_ID]), + }; + const vpnService: ResolvedService = { + name: 'vpn', + authorizationScope: VPN_SCOPE, + clientIdSet: new Set([VPN_CLIENT_ID, SHARED_CLIENT_ID]), + }; + const indexes = buildServiceIndexes([relayService, vpnService]); + + it('matches scope+clientId when both align', () => { + const clientId = Buffer.from(RELAY_CLIENT_ID, 'hex'); + const result = findMatchingServices(clientId, RELAY_SCOPE, indexes); + expect(result.matches.map((s) => s.name)).toEqual(['relay']); + expect(result.isAmbiguous).toBe(false); + }); + + it('rejects scope-only match when clientId is not a minter for that service', () => { + const unknownClientId = Buffer.from('aaaa000000000000', 'hex'); + const result = findMatchingServices(unknownClientId, RELAY_SCOPE, indexes); + expect(result.matches).toHaveLength(0); + }); + + it('falls back to clientId-only when scope does not match and clientId maps to a single service', () => { + const clientId = Buffer.from(RELAY_CLIENT_ID, 'hex'); + const result = findMatchingServices(clientId, 'profile', indexes); + expect(result.matches.map((s) => s.name)).toEqual(['relay']); + }); + + it('does not fall back to clientId-only when clientId maps to multiple services', () => { + const sharedClientId = Buffer.from(SHARED_CLIENT_ID, 'hex'); + const result = findMatchingServices(sharedClientId, 'profile', indexes); + expect(result.matches).toHaveLength(0); + }); + + it('matches multiple services when token has multiple matching scopes AND clientId mints all of them', () => { + const sharedClientId = Buffer.from(SHARED_CLIENT_ID, 'hex'); + const result = findMatchingServices( + sharedClientId, + `${RELAY_SCOPE} ${VPN_SCOPE}`, + indexes + ); + expect(result.matches.map((s) => s.name).sort()).toEqual(['relay', 'vpn']); + }); + + it('only matches the service for which clientId mints when token has multiple service scopes', () => { + const relayOnlyClientId = Buffer.from(RELAY_CLIENT_ID, 'hex'); + const result = findMatchingServices( + relayOnlyClientId, + `${RELAY_SCOPE} ${VPN_SCOPE}`, + indexes + ); + expect(result.matches.map((s) => s.name)).toEqual(['relay']); + }); + + it('matches scope when scope appears in a space-separated scope string', () => { + const clientId = Buffer.from(RELAY_CLIENT_ID, 'hex'); + const result = findMatchingServices( + clientId, + `profile ${RELAY_SCOPE} openid`, + indexes + ); + expect(result.matches.map((s) => s.name)).toEqual(['relay']); + }); + + it('does not match a scope that is a prefix of a service scope', () => { + const unknownClientId = Buffer.from('aaaa000000000000', 'hex'); + const result = findMatchingServices( + unknownClientId, + 'https://identity.mozilla.com/apps/rela', + indexes + ); + expect(result.matches).toHaveLength(0); + }); + + it('does not match a scope that has the service scope as a prefix', () => { + const unknownClientId = Buffer.from('aaaa000000000000', 'hex'); + const result = findMatchingServices( + unknownClientId, + `${RELAY_SCOPE}-extra`, + indexes + ); + expect(result.matches).toHaveLength(0); + }); + + it('returns no matches for empty scope and unknown clientId', () => { + const unknownClientId = Buffer.from('aaaa000000000000', 'hex'); + const result = findMatchingServices(unknownClientId, '', indexes); + expect(result.matches).toHaveLength(0); + expect(result.isAmbiguous).toBe(false); + }); + + it('is case-insensitive for clientId matching', () => { + const upperHex = RELAY_CLIENT_ID.toUpperCase(); + const clientId = Buffer.from(upperHex, 'hex'); + const result = findMatchingServices(clientId, RELAY_SCOPE, indexes); + expect(result.matches.map((s) => s.name)).toEqual(['relay']); + }); + + it('flags Firefox Desktop as ambiguous and skips clientId-only fallback', () => { + const ambiguousClientId = Buffer.from(FIREFOX_DESKTOP_CLIENT_ID, 'hex'); + // Add Firefox Desktop to relay's clientIds so scope+clientId could match. + const relayWithDesktop: ResolvedService = { + ...relayService, + clientIdSet: new Set([ + ...relayService.clientIdSet, + FIREFOX_DESKTOP_CLIENT_ID, + ]), + }; + const idxWithDesktop = buildServiceIndexes([relayWithDesktop, vpnService]); + + // No scope match → no clientId fallback either. + const noScope = findMatchingServices( + ambiguousClientId, + 'profile', + idxWithDesktop + ); + expect(noScope.matches).toHaveLength(0); + expect(noScope.isAmbiguous).toBe(true); + + // Scope+clientId match still works for ambiguous clientIds. + const withScope = findMatchingServices( + ambiguousClientId, + RELAY_SCOPE, + idxWithDesktop + ); + expect(withScope.matches.map((s) => s.name)).toEqual(['relay']); + expect(withScope.isAmbiguous).toBe(true); + }); +}); + +describe('buildServiceIndexes', () => { + const relayService: ResolvedService = { + name: 'relay', + authorizationScope: RELAY_SCOPE, + clientIdSet: new Set([RELAY_CLIENT_ID, 'aabbccdd00000001']), + }; + const vpnService: ResolvedService = { + name: 'vpn', + authorizationScope: VPN_SCOPE, + clientIdSet: new Set([VPN_CLIENT_ID, 'aabbccdd00000001']), + }; + + it('indexes services by name, scope, and clientId', () => { + const idx = buildServiceIndexes([relayService, vpnService]); + expect(idx.byName.get('relay')).toBe(relayService); + expect(idx.scopeIdx.get(RELAY_SCOPE)).toBe('relay'); + expect(idx.scopeIdx.get(VPN_SCOPE)).toBe('vpn'); + expect(idx.clientIdIdx.get(RELAY_CLIENT_ID)).toEqual(['relay']); + }); + + it('records all services that share a clientId', () => { + const idx = buildServiceIndexes([relayService, vpnService]); + expect(idx.clientIdIdx.get('aabbccdd00000001')?.sort()).toEqual([ + 'relay', + 'vpn', + ]); + }); +}); + +describe('parseCursorHex', () => { + const validHex = 'a'.repeat(64); + + it('returns a 32-byte Buffer for valid 64-char hex', () => { + const buf = parseCursorHex('start-cursor', validHex); + expect(buf).toHaveLength(32); + expect(buf.toString('hex')).toBe(validHex); + }); + + it('accepts upper-case hex', () => { + const buf = parseCursorHex('start-cursor', validHex.toUpperCase()); + expect(buf).toHaveLength(32); + }); + + it('throws when hex is shorter than 64 chars', () => { + expect(() => parseCursorHex('start-cursor', 'a'.repeat(63))).toThrow( + 'must be exactly 64 hex chars' + ); + }); + + it('throws when hex is longer than 64 chars', () => { + expect(() => parseCursorHex('start-cursor', 'a'.repeat(65))).toThrow( + 'must be exactly 64 hex chars' + ); + }); + + it('throws when input contains non-hex characters', () => { + const bad = 'g'.repeat(64); + expect(() => parseCursorHex('start-cursor', bad)).toThrow( + 'must be exactly 64 hex chars' + ); + }); + + it('includes the flag name in the error message', () => { + expect(() => parseCursorHex('end-cursor', '')).toThrow('--end-cursor'); + }); +}); + +describe('workerCursors', () => { + it('worker 0 starts at all-zero buffer', () => { + const { startCursor } = workerCursors(0, 4); + expect(startCursor).toEqual(Buffer.alloc(32, 0)); + }); + + it('last worker ends at all-0xFF buffer', () => { + const { endCursor } = workerCursors(3, 4); + expect(endCursor).toEqual(Buffer.alloc(32, 0xff)); + }); + + it('adjacent workers cover the full keyspace without gaps', () => { + const workerCount = 4; + for (let i = 0; i < workerCount - 1; i++) { + const { endCursor } = workerCursors(i, workerCount); + const { startCursor } = workerCursors(i + 1, workerCount); + // worker N's endCursor is the exclusive lower bound for worker N+1: + // startCursor[1..] is 0xFF and startCursor[0] == endCursor[0] + expect(startCursor[0]).toBe(endCursor[0]); + expect(startCursor.subarray(1)).toEqual(Buffer.alloc(31, 0xff)); + } + }); + + it('throws when workerIndex equals workerCount', () => { + expect(() => workerCursors(4, 4)).toThrow('--worker-index must be 0'); + }); + + it('throws when workerIndex is negative', () => { + expect(() => workerCursors(-1, 4)).toThrow('--worker-index must be 0'); + }); + + it('throws when workerCount is 0', () => { + expect(() => workerCursors(0, 0)).toThrow('--worker-count must be'); + }); + + it('throws when workerCount exceeds 256', () => { + expect(() => workerCursors(0, 257)).toThrow('--worker-count must be'); + }); + + it('throws when workerCount is not an integer', () => { + expect(() => workerCursors(0, 1.5)).toThrow('--worker-count must be'); + }); + + it('throws when workerIndex is not an integer', () => { + expect(() => workerCursors(1.5, 4)).toThrow('--worker-index must be 0'); + }); + + it('returns 32-byte buffers for both cursors', () => { + const { startCursor, endCursor } = workerCursors(2, 8); + expect(startCursor).toHaveLength(32); + expect(endCursor).toHaveLength(32); + }); + + it('works for a single worker (workerCount = 1)', () => { + const { startCursor, endCursor } = workerCursors(0, 1); + expect(startCursor).toEqual(Buffer.alloc(32, 0)); + expect(endCursor).toEqual(Buffer.alloc(32, 0xff)); + }); +}); + +describe('batchUpsert', () => { + it('does nothing and does not call query when rows is empty', async () => { + const query = makeQuery(); + await batchUpsert(query, []); + expect(query).not.toHaveBeenCalled(); + }); + + it('calls query with one value group for a single row', async () => { + const query = makeQuery(); + const uid = Buffer.alloc(16, 0xaa); + const rows: UpsertRow[] = [ + { + uid, + scope: RELAY_SCOPE, + service: 'relay', + authorizedAt: 1000, + }, + ]; + await batchUpsert(query, rows); + expect(query).toHaveBeenCalledTimes(1); + const [sql, values] = query.mock.calls[0]; + expect(sql).toContain('INSERT INTO accountAuthorizations'); + expect(sql).toContain('ON DUPLICATE KEY UPDATE'); + expect(values).toEqual([uid, RELAY_SCOPE, 'relay', 1000]); + }); + + it('builds one placeholder group per row for multiple rows', async () => { + const query = makeQuery(); + const rows: UpsertRow[] = [ + { + uid: Buffer.alloc(16, 0x01), + scope: RELAY_SCOPE, + service: 'relay', + authorizedAt: 1000, + }, + { + uid: Buffer.alloc(16, 0x02), + scope: VPN_SCOPE, + service: 'vpn', + authorizedAt: 3000, + }, + ]; + await batchUpsert(query, rows); + const [sql, values] = query.mock.calls[0]; + expect((sql.match(/\(\?, \?, \?, \?\)/g) ?? []).length).toBe(2); + expect(values).toHaveLength(8); + }); + + it('uses LEAST for authorizedAt in the upsert clause', async () => { + const query = makeQuery(); + await batchUpsert(query, [ + { + uid: Buffer.alloc(16), + scope: RELAY_SCOPE, + service: 'relay', + authorizedAt: 1000, + }, + ]); + const [sql] = query.mock.calls[0]; + expect(sql).toContain('LEAST(authorizedAt'); + }); +}); + +describe('writeCheckpoint', () => { + it('writes the cursor as a hex string to the given file path', () => { + const log = makeLog(); + const cursor = Buffer.from('deadbeef'.padEnd(64, '0'), 'hex'); + + writeCheckpoint(cursor, '/tmp/cursor.txt', log); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/tmp/cursor.txt', + cursor.toString('hex'), + 'utf8' + ); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('emits a warn log and does not throw when the write fails', () => { + (fs.writeFileSync as jest.Mock).mockImplementation(() => { + throw new Error('ENOENT'); + }); + const log = makeLog(); + const cursor = Buffer.alloc(32, 0xab); + + expect(() => + writeCheckpoint(cursor, '/no/such/path.txt', log) + ).not.toThrow(); + expect(log.warn).toHaveBeenCalledWith( + 'backfill.checkpoint.write_failed', + expect.objectContaining({ filePath: '/no/such/path.txt' }) + ); + }); +}); + +describe('fetchBatch', () => { + const cursor = Buffer.alloc(32, 0x00); + const endCursor = Buffer.alloc(32, 0xff); + + it('uses a range query (token <= endCursor) when endCursor is provided', async () => { + const query = makeQuery([]); + await fetchBatch(query, cursor, endCursor, 100); + const [sql, values] = query.mock.calls[0]; + expect(sql).toContain('token <= ?'); + expect(values).toEqual([cursor, endCursor, 100]); + }); + + it('uses an open-ended query when endCursor is null', async () => { + const query = makeQuery([]); + await fetchBatch(query, cursor, null, 100); + const [sql, values] = query.mock.calls[0]; + expect(sql).not.toContain('token <='); + expect(values).toEqual([cursor, 100]); + }); + + it('always uses an exclusive lower bound (token > cursor)', async () => { + const query = makeQuery([]); + await fetchBatch(query, cursor, null, 50); + const [sql] = query.mock.calls[0]; + expect(sql).toContain('token > ?'); + }); + + it('passes batchSize as the LIMIT parameter', async () => { + const query = makeQuery([]); + await fetchBatch(query, cursor, null, 250); + const values = query.mock.calls[0][1]; + expect(values[values.length - 1]).toBe(250); + }); +}); + +describe('estimateTotalTokens', () => { + it('returns TABLE_ROWS from information_schema', async () => { + const query = makeQuery([{ TABLE_ROWS: 42000 }]); + const total = await estimateTotalTokens(query); + expect(total).toBe(42000); + }); + + it('returns 0 when the query returns no rows', async () => { + const query = makeQuery([]); + const total = await estimateTotalTokens(query); + expect(total).toBe(0); + }); +}); + +describe('withRetry', () => { + function retryCtx(overrides: { attempts?: number } = {}) { + return { + opName: 'test', + attempts: overrides.attempts ?? 3, + initialDelayMs: 1, // keep tests fast + log: makeLog(), + }; + } + + it('returns the value on first success without retrying', async () => { + const op = jest.fn().mockResolvedValue('ok'); + const result = await withRetry(op, retryCtx()); + expect(result).toBe('ok'); + expect(op).toHaveBeenCalledTimes(1); + }); + + it('retries on a transient error and succeeds on a later attempt', async () => { + const transient = Object.assign(new Error('reset'), { + code: 'ECONNRESET', + }); + const op = jest + .fn() + .mockRejectedValueOnce(transient) + .mockResolvedValue('ok'); + const ctx = retryCtx(); + const result = await withRetry(op, ctx); + expect(result).toBe('ok'); + expect(op).toHaveBeenCalledTimes(2); + expect(ctx.log.warn).toHaveBeenCalledWith( + 'backfill.retry', + expect.objectContaining({ op: 'test', attempt: 1, code: 'ECONNRESET' }) + ); + }); + + it('throws after exhausting all attempts', async () => { + const transient = Object.assign(new Error('timeout'), { + code: 'ETIMEDOUT', + }); + const op = jest.fn().mockRejectedValue(transient); + await expect(withRetry(op, retryCtx({ attempts: 3 }))).rejects.toThrow( + 'timeout' + ); + expect(op).toHaveBeenCalledTimes(3); + }); + + it('does not retry non-transient errors', async () => { + const fatal = new Error('syntax error'); // no code + const op = jest.fn().mockRejectedValue(fatal); + await expect(withRetry(op, retryCtx())).rejects.toThrow('syntax error'); + expect(op).toHaveBeenCalledTimes(1); + }); + + it('does not retry errors whose code is not in the transient set', async () => { + const fatal = Object.assign(new Error('access denied'), { + code: 'ER_ACCESS_DENIED_ERROR', + }); + const op = jest.fn().mockRejectedValue(fatal); + await expect(withRetry(op, retryCtx())).rejects.toThrow('access denied'); + expect(op).toHaveBeenCalledTimes(1); + }); +}); + +describe('run', () => { + const relayService: ResolvedService = { + name: 'relay', + authorizationScope: RELAY_SCOPE, + clientIdSet: new Set([RELAY_CLIENT_ID]), + }; + const relayIndexes: ServiceIndexes = buildServiceIndexes([relayService]); + + const baseOpts = { + dryRun: false, + batchSize: 100, + batchDelayMs: 0, + startCursor: Buffer.alloc(32, 0), + endCursor: null, + checkpointFile: '/tmp/test-cursor.txt', + retryInitialDelayMs: 1, // keep tests fast in case any error path retries + }; + + beforeEach(() => { + (fs.writeFileSync as jest.Mock).mockImplementation(() => {}); + }); + + function makeTokenRow( + overrides: Partial<{ + token: Buffer; + userId: Buffer; + clientId: Buffer; + scope: string; + createdAt: Date; + }> = {} + ) { + return { + token: Buffer.alloc(32, 0x01), + userId: Buffer.alloc(16, 0xaa), + clientId: Buffer.from(RELAY_CLIENT_ID, 'hex'), + scope: RELAY_SCOPE, + createdAt: new Date(1000), + ...overrides, + }; + } + + it('completes cleanly when the table is empty', async () => { + const log = makeLog(); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 0 }]) // estimateTotalTokens + .mockResolvedValueOnce([]); // fetchBatch → empty, loop never enters + await run(query, relayIndexes, log, baseOpts); + expect(log.info).toHaveBeenCalledWith( + 'backfill.complete', + expect.objectContaining({ tokensScanned: 0, upsertsAttempted: 0 }) + ); + }); + + it('does not call INSERT when dryRun is true', async () => { + const log = makeLog(); + const tokenRow = makeTokenRow(); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) // estimateTotalTokens + .mockResolvedValueOnce([tokenRow]); // fetchBatch → one page + await run(query, relayIndexes, log, { ...baseOpts, dryRun: true }); + const insertCalls = query.mock.calls.filter(([sql]) => + sql?.includes?.('INSERT INTO') + ); + expect(insertCalls).toHaveLength(0); + }); + + it('calls INSERT when dryRun is false and a token matches', async () => { + const log = makeLog(); + const tokenRow = makeTokenRow(); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) // estimateTotalTokens + .mockResolvedValueOnce([tokenRow]) // fetchBatch → one matching row + .mockResolvedValueOnce(undefined); // batchUpsert INSERT + await run(query, relayIndexes, log, baseOpts); + const insertCalls = query.mock.calls.filter(([sql]) => + sql?.includes?.('INSERT INTO accountAuthorizations') + ); + expect(insertCalls).toHaveLength(1); + }); + + it('warns when no rows are upserted and dryRun is false', async () => { + const log = makeLog(); + const nonMatchingRow = makeTokenRow({ + clientId: Buffer.alloc(8, 0x00), + scope: 'profile', + }); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) // estimateTotalTokens + .mockResolvedValueOnce([nonMatchingRow]); // fetchBatch → no match + await run(query, relayIndexes, log, baseOpts); + expect(log.warn).toHaveBeenCalledWith( + 'backfill.no_rows_upserted', + expect.any(Object) + ); + }); + + it('does not warn about zero upserts when dryRun is true', async () => { + const log = makeLog(); + const nonMatchingRow = makeTokenRow({ scope: 'profile' }); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) // estimateTotalTokens + .mockResolvedValueOnce([nonMatchingRow]); // fetchBatch + await run(query, relayIndexes, log, { ...baseOpts, dryRun: true }); + expect(log.warn).not.toHaveBeenCalledWith( + 'backfill.no_rows_upserted', + expect.any(Object) + ); + }); + + it('logs per-service counts in backfill.complete', async () => { + const log = makeLog(); + const tokenRow = makeTokenRow(); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) + .mockResolvedValueOnce([tokenRow]) + .mockResolvedValueOnce(undefined); + await run(query, relayIndexes, log, baseOpts); + expect(log.info).toHaveBeenCalledWith( + 'backfill.complete', + expect.objectContaining({ + countsByService: { relay: 1 }, + upsertsAttempted: 1, + }) + ); + }); + + it('writes a checkpoint after each batch', async () => { + const log = makeLog(); + const tokenRow = makeTokenRow({ token: Buffer.alloc(32, 0x42) }); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) + .mockResolvedValueOnce([tokenRow]) + .mockResolvedValueOnce(undefined); + await run(query, relayIndexes, log, baseOpts); + expect(fs.writeFileSync).toHaveBeenCalledWith( + baseOpts.checkpointFile, + tokenRow.token.toString('hex'), + 'utf8' + ); + }); + + it('continues fetching until a partial batch ends the loop', async () => { + const log = makeLog(); + const opts = { ...baseOpts, batchSize: 2 }; + // batch 1: 2 rows (full batch — loop continues) + // batch 2: 1 row (partial — loop exits after this) + const fullBatch = [ + makeTokenRow({ token: Buffer.alloc(32, 0x01) }), + makeTokenRow({ token: Buffer.alloc(32, 0x02) }), + ]; + const partialBatch = [makeTokenRow({ token: Buffer.alloc(32, 0x03) })]; + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 3 }]) // estimateTotalTokens + .mockResolvedValueOnce(fullBatch) // fetchBatch #1 + .mockResolvedValueOnce(undefined) // batchUpsert #1 + .mockResolvedValueOnce(partialBatch) // fetchBatch #2 + .mockResolvedValueOnce(undefined); // batchUpsert #2 + await run(query, relayIndexes, log, opts); + + const fetchCalls = query.mock.calls.filter(([sql]) => + sql?.includes?.('FROM refreshTokens') + ); + const insertCalls = query.mock.calls.filter(([sql]) => + sql?.includes?.('INSERT INTO accountAuthorizations') + ); + expect(fetchCalls).toHaveLength(2); + expect(insertCalls).toHaveLength(2); + expect(log.info).toHaveBeenCalledWith( + 'backfill.complete', + expect.objectContaining({ tokensScanned: 3, upsertsAttempted: 3 }) + ); + }); + + it('advances the cursor between batches using the last token in the previous batch', async () => { + const log = makeLog(); + const opts = { ...baseOpts, batchSize: 1 }; + const firstToken = Buffer.alloc(32, 0x11); + const secondToken = Buffer.alloc(32, 0x22); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 2 }]) + .mockResolvedValueOnce([makeTokenRow({ token: firstToken })]) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([makeTokenRow({ token: secondToken })]) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([]); + await run(query, relayIndexes, log, opts); + + // Second fetchBatch should be called with the first token as its cursor. + const fetchCalls = query.mock.calls.filter(([sql]) => + sql?.includes?.('FROM refreshTokens') + ); + expect(fetchCalls.length).toBeGreaterThanOrEqual(2); + const secondFetchCursor = fetchCalls[1][1][0]; + expect(secondFetchCursor).toEqual(firstToken); + }); + + it('propagates batchUpsert errors and logs upsert_batch_failed', async () => { + const log = makeLog(); + const tokenRow = makeTokenRow(); + const upsertError = new Error('connection lost'); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) + .mockResolvedValueOnce([tokenRow]) + .mockRejectedValueOnce(upsertError); + + await expect(run(query, relayIndexes, log, baseOpts)).rejects.toThrow( + 'connection lost' + ); + expect(log.error).toHaveBeenCalledWith( + 'backfill.upsert_batch_failed', + expect.objectContaining({ err: 'connection lost' }) + ); + }); + + it('retries batchUpsert on a transient error and succeeds', async () => { + const log = makeLog(); + const tokenRow = makeTokenRow(); + const transient = Object.assign(new Error('reset'), { + code: 'ECONNRESET', + }); + const query = jest + .fn() + .mockResolvedValueOnce([{ TABLE_ROWS: 1 }]) // estimateTotalTokens + .mockResolvedValueOnce([tokenRow]) // fetchBatch + .mockRejectedValueOnce(transient) // batchUpsert (1st attempt) — fail + .mockResolvedValueOnce(undefined); // batchUpsert (2nd attempt) — succeed + + await run(query, relayIndexes, log, { + ...baseOpts, + retryAttempts: 3, + retryInitialDelayMs: 1, + }); + + const insertCalls = query.mock.calls.filter(([sql]) => + sql?.includes?.('INSERT INTO accountAuthorizations') + ); + expect(insertCalls).toHaveLength(2); + expect(log.warn).toHaveBeenCalledWith( + 'backfill.retry', + expect.objectContaining({ op: 'batchUpsert', code: 'ECONNRESET' }) + ); + expect(log.info).toHaveBeenCalledWith( + 'backfill.complete', + expect.objectContaining({ upsertsAttempted: 1 }) + ); + }); +}); diff --git a/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations.ts b/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations.ts new file mode 100644 index 00000000000..8cde7446e40 --- /dev/null +++ b/packages/fxa-auth-server/scripts/backfill-account-authorizations/backfill-account-authorizations.ts @@ -0,0 +1,782 @@ +/* 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/. */ + +/** + * Backfill script for the accountAuthorizations table. + * + * Makes a single ordered pass through the refreshTokens table and upserts a + * row into accountAuthorizations for every token whose scope or clientId maps + * to a configured browserService. One pass covers all services. + * + * Prerequisites: + * - accountAuthorizations table created (FXA-12931 migration deployed) + * - browserServices populated in config (clientIds, authorizationScope, etc.) + * + * Usage (from packages/fxa-auth-server): + * + * # Single-process (suitable up to ~50M tokens) + * node -r ts-node scripts/backfill-account-authorizations/backfill-account-authorizations.ts --dry-run + * node -r ts-node scripts/backfill-account-authorizations/backfill-account-authorizations.ts + * + * # Resume after interruption + * node -r ts-node scripts/backfill-account-authorizations/backfill-account-authorizations.ts \ + * --start-cursor $(cat temp/backfill-cursor.txt) + * + * # Parallel workers — splits the token keyspace evenly across N processes. + * # token is a SHA256 hash so values distribute uniformly across the key space. + * # Run each command in a separate terminal / tmux pane: + * node -r ts-node scripts/backfill-account-authorizations/backfill-account-authorizations.ts --worker-index 0 --worker-count 8 + * node -r ts-node scripts/backfill-account-authorizations/backfill-account-authorizations.ts --worker-index 1 --worker-count 8 + * # ... up to worker-index 7 + * # Each worker writes its own checkpoint: temp/backfill-cursor-worker-0.txt, etc. + * + * # Filter to a single service + * node -r ts-node scripts/backfill-account-authorizations/backfill-account-authorizations.ts --service relay + * + * Execution plan: + * 1. --dry-run on stage → verify per-service counts + * 2. Live run on stage → spot-check accountAuthorizations rows + * 3. --dry-run on prod → validate expected counts + * 4. Live run on prod (off-peak) → parallel workers if table > 50M rows + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as mysql from 'mysql'; +import program from 'commander'; +import { StatsD } from 'hot-shots'; +import pckg from '../../package.json'; +import { SERVICE_AMBIGUOUS_CLIENT_IDS } from '../../lib/oauth/browser-services'; + +export interface BrowserServiceConfig { + displayName: string; + authorizationScope: string; + clientIds: string[]; + serviceParams: string[]; + retentionDays: number; + allowSilentExchange: boolean; +} + +export interface ResolvedService { + name: string; + authorizationScope: string; + clientIdSet: Set; // lower-case hex strings for O(1) lookup +} + +export interface ServiceIndexes { + byName: Map; + scopeIdx: Map; // scope value → service name + clientIdIdx: Map; // lowercase clientId hex → service names +} + +export interface MatchResult { + matches: ResolvedService[]; + isAmbiguous: boolean; +} + +export interface UpsertRow { + uid: Buffer; + scope: string; + service: string; + authorizedAt: number; // ms epoch +} + +export interface Logger { + info: (op: string, data?: unknown) => void; + warn: (op: string, data?: unknown) => void; + error: (op: string, data?: unknown) => void; + debug?: (op: string, data?: unknown) => void; +} + +export type Query = ( + sql: string, + values?: unknown[] +) => Promise; + +function makeQuery(pool: mysql.Pool): Query { + return (sql: string, values?: unknown[]): Promise => + new Promise((resolve, reject) => { + pool.query(sql, values, (err, results) => { + if (err) reject(err); + else resolve(results as T); + }); + }); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function humanDuration(ms: number): string { + const totalSec = Math.floor(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + if (h > 0) return `${h}h${m}m${s}s`; + if (m > 0) return `${m}m${s}s`; + return `${s}s`; +} + +// Always returns ALL services so the ambiguity check for single-service +// clientId fallback can see the full picture. Use `serviceFilter` at output +// time (in run()) to narrow which rows actually get written. +function resolveServices( + rawConfig: Record +): ResolvedService[] { + const names = Object.keys(rawConfig); + if (names.length === 0) { + throw new Error( + 'browserServices config is empty. ' + + 'Add at least one service entry (relay, vpn, etc.) to config or config/secrets.json.' + ); + } + return names.map((name) => buildResolvedService(name, rawConfig[name])); +} + +function buildResolvedService( + name: string, + cfg: BrowserServiceConfig +): ResolvedService { + return { + name, + authorizationScope: cfg.authorizationScope, + clientIdSet: new Set((cfg.clientIds || []).map((id) => id.toLowerCase())), + }; +} + +// Refuse silently-incorrect cursors. A short or non-hex value would deserialize +// to an unexpected Buffer length, which the SQL `token > ?` comparison would +// happily accept — the backfill would then either skip rows or scan from zero +// without warning. +function parseCursorHex(flag: string, hex: string): Buffer { + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error( + `--${flag} must be exactly 64 hex chars (32 bytes); got "${hex}" (${hex.length} chars)` + ); + } + return Buffer.from(hex, 'hex'); +} + +function buildServiceIndexes(services: ResolvedService[]): ServiceIndexes { + const byName = new Map(); + const scopeIdx = new Map(); + const clientIdIdx = new Map(); + + for (const svc of services) { + byName.set(svc.name, svc); + scopeIdx.set(svc.authorizationScope, svc.name); + for (const id of svc.clientIdSet) { + const list = clientIdIdx.get(id) ?? []; + list.push(svc.name); + clientIdIdx.set(id, list); + } + } + + return { byName, scopeIdx, clientIdIdx }; +} + +// Mirrors lib/oauth/browser-services.ts:recordAuthorizationOnLogin matching: +// - scope match requires the clientId to be a recognized minter for the +// matched service (so an RP that obtains the scope can't plant rows for +// services it doesn't represent) +// - clientId-only fallback only fires when the clientId maps to exactly +// one service (multi-service clientIds are ambiguous without scope) +// - SERVICE_AMBIGUOUS_CLIENT_IDS (Firefox Desktop) require serviceParam at +// runtime; refreshTokens doesn't store serviceParam, so we restrict to +// scope+clientId matches only and report the gap via metrics. +function findMatchingServices( + clientId: Buffer, + scopeStr: string, + indexes: ServiceIndexes +): MatchResult { + const clientHex = clientId.toString('hex').toLowerCase(); + const isAmbiguous = SERVICE_AMBIGUOUS_CLIENT_IDS.has(clientHex); + const scopes = scopeStr.split(/\s+/).filter(Boolean); + + const matched = new Map(); + + for (const value of scopes) { + const name = indexes.scopeIdx.get(value); + if (!name || matched.has(name)) continue; + const svc = indexes.byName.get(name); + if (!svc || !svc.clientIdSet.has(clientHex)) continue; + matched.set(name, svc); + } + + if (matched.size === 0 && !isAmbiguous) { + const candidates = indexes.clientIdIdx.get(clientHex); + if (candidates && candidates.length === 1) { + const svc = indexes.byName.get(candidates[0]); + if (svc) matched.set(svc.name, svc); + } + } + + return { matches: [...matched.values()], isAmbiguous }; +} + +async function batchUpsert(query: Query, rows: UpsertRow[]): Promise { + if (rows.length === 0) return; + + const placeholders = rows.map(() => '(?, ?, ?, ?)').join(', '); + const values = rows.flatMap((r) => [ + r.uid, + r.scope, + r.service, + r.authorizedAt, + ]); + + await query( + `INSERT INTO accountAuthorizations (uid, scope, service, authorizedAt) + VALUES ${placeholders} + ON DUPLICATE KEY UPDATE + authorizedAt = LEAST(authorizedAt, VALUES(authorizedAt))`, + values + ); +} + +function writeCheckpoint(cursor: Buffer, filePath: string, log: Logger): void { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, cursor.toString('hex'), 'utf8'); + } catch (err) { + log.warn('backfill.checkpoint.write_failed', { + filePath, + err, + cursor: cursor.toString('hex'), + }); + } +} + +async function estimateTotalTokens(query: Query): Promise { + // InnoDB TABLE_ROWS is an approximation but fine for progress display. + const rows = await query>( + `SELECT TABLE_ROWS FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refreshTokens'` + ); + return rows[0]?.TABLE_ROWS ?? 0; +} + +/** + * Splits the full BINARY(32) token keyspace into `workerCount` equal ranges + * and returns the [startCursor, endCursor] pair for `workerIndex`. + * + * Tokens are SHA256 hashes so they distribute uniformly, making byte-boundary + * splits an effective way to parallelize without hotspots. + */ +function workerCursors( + workerIndex: number, + workerCount: number +): { startCursor: Buffer; endCursor: Buffer } { + // Bounded by the size of the first byte of the keyspace (256 values). More + // than 256 workers would produce zero-byte ranges that overlap or skip rows. + if (!Number.isInteger(workerCount) || workerCount < 1 || workerCount > 256) { + throw new Error( + `--worker-count must be an integer between 1 and 256; got ${workerCount}` + ); + } + if ( + !Number.isInteger(workerIndex) || + workerIndex < 0 || + workerIndex >= workerCount + ) { + throw new Error( + `--worker-index must be 0–${workerCount - 1} when --worker-count is ${workerCount}` + ); + } + + const bytesPerWorker = Math.ceil(256 / workerCount); + const firstByteStart = workerIndex * bytesPerWorker; + const firstByteEnd = Math.min(firstByteStart + bytesPerWorker - 1, 255); + + // startCursor is an exclusive lower bound (WHERE token > startCursor). + // For worker N>0, set byte[0] = firstByteStart - 1 with 0xff fill so the + // cursor lands just before the first token in this worker's range. + const startCursor = Buffer.alloc(32, 0); + if (workerIndex > 0) { + startCursor[0] = firstByteStart - 1; + startCursor.fill(0xff, 1); + } + + const endCursor = Buffer.alloc(32, 0xff); + endCursor[0] = firstByteEnd; + + return { startCursor, endCursor }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Codes the mysql driver / Node net stack surface for transient blips. A +// single packet hiccup or short lock contention shouldn't kill a multi-hour +// backfill — we retry, and only propagate the error when the same op fails +// repeatedly. Anything not in this set (schema errors, syntax errors, etc.) +// fails fast. +const TRANSIENT_DB_ERROR_CODES: ReadonlySet = new Set([ + 'PROTOCOL_CONNECTION_LOST', + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'ER_LOCK_WAIT_TIMEOUT', + 'ER_LOCK_DEADLOCK', +]); + +async function withRetry( + operation: () => Promise, + context: { + opName: string; + attempts: number; + initialDelayMs: number; + log: Logger; + statsd?: StatsD; + } +): Promise { + let lastErr: unknown; + for (let attempt = 1; attempt <= context.attempts; attempt++) { + try { + return await operation(); + } catch (err) { + lastErr = err; + const code = (err as { code?: string }).code; + const isTransient = !!code && TRANSIENT_DB_ERROR_CODES.has(code); + if (!isTransient || attempt === context.attempts) { + throw err; + } + const delayMs = context.initialDelayMs * Math.pow(2, attempt - 1); + context.log.warn('backfill.retry', { + op: context.opName, + attempt, + nextDelayMs: delayMs, + code, + err: errMessage(err), + }); + context.statsd?.increment('account_authz.backfill.retry', { + op: context.opName, + code, + }); + await sleep(delayMs); + } + } + throw lastErr; +} + +type TokenRow = { + token: Buffer; + userId: Buffer; + clientId: Buffer; + scope: string; + createdAt: Date; +}; + +async function fetchBatch( + query: Query, + cursor: Buffer, + endCursor: Buffer | null, + batchSize: number +): Promise { + if (endCursor) { + return query( + 'SELECT token, userId, clientId, scope, createdAt ' + + 'FROM refreshTokens WHERE token > ? AND token <= ? ORDER BY token LIMIT ?', + [cursor, endCursor, batchSize] + ); + } + return query( + 'SELECT token, userId, clientId, scope, createdAt ' + + 'FROM refreshTokens WHERE token > ? ORDER BY token LIMIT ?', + [cursor, batchSize] + ); +} + +async function run( + query: Query, + indexes: ServiceIndexes, + log: Logger, + opts: { + dryRun: boolean; + batchSize: number; + batchDelayMs: number; + startCursor: Buffer; + endCursor: Buffer | null; + checkpointFile: string; + serviceFilter?: string; + statsd?: StatsD; + retryAttempts?: number; + retryInitialDelayMs?: number; + } +): Promise { + const { + dryRun, + batchSize, + batchDelayMs, + checkpointFile, + serviceFilter, + statsd, + } = opts; + const retryAttempts = opts.retryAttempts ?? 3; + const retryInitialDelayMs = opts.retryInitialDelayMs ?? 250; + const withDbRetry = (opName: string, op: () => Promise): Promise => + withRetry(op, { + opName, + attempts: retryAttempts, + initialDelayMs: retryInitialDelayMs, + log, + statsd, + }); + + const runStartedMs = Date.now(); + const estimated = await estimateTotalTokens(query); + const allServiceNames = [...indexes.byName.keys()]; + log.info('backfill.start', { + estimatedTokens: estimated, + services: allServiceNames, + serviceFilter: serviceFilter ?? null, + batchSize, + batchDelayMs, + dryRun, + startCursor: opts.startCursor.toString('hex').slice(0, 8), + endCursor: opts.endCursor?.toString('hex').slice(0, 8) ?? null, + }); + + let cursor = opts.startCursor; + let batchNum = 0; + let totalTokensProcessed = 0; + let totalUpsertsAttempted = 0; + let totalSkippedAmbiguous = 0; + let totalSkippedNoMatch = 0; + const countsByService: Record = {}; + for (const name of allServiceNames) countsByService[name] = 0; + + let rows = await withDbRetry('fetchBatch', () => + fetchBatch(query, cursor, opts.endCursor, batchSize) + ); + + while (rows.length > 0) { + batchNum++; + totalTokensProcessed += rows.length; + const batchStartedMs = Date.now(); + + const upserts: UpsertRow[] = []; + + for (const row of rows) { + const { matches, isAmbiguous } = findMatchingServices( + row.clientId, + row.scope, + indexes + ); + + if (matches.length === 0) { + if (isAmbiguous) { + totalSkippedAmbiguous++; + statsd?.increment('account_authz.backfill.tokens_skipped', { + reason: 'ambiguous_no_match', + }); + } else { + totalSkippedNoMatch++; + statsd?.increment('account_authz.backfill.tokens_skipped', { + reason: 'no_match', + }); + } + continue; + } + + for (const svc of matches) { + if (serviceFilter && svc.name !== serviceFilter) continue; + upserts.push({ + uid: row.userId, + scope: svc.authorizationScope, + service: svc.name, + authorizedAt: row.createdAt.getTime(), + }); + countsByService[svc.name]++; + statsd?.increment('account_authz.backfill.upserts_attempted', { + service: svc.name, + }); + } + } + + if (!dryRun && upserts.length > 0) { + try { + await withDbRetry('batchUpsert', () => batchUpsert(query, upserts)); + } catch (err) { + statsd?.increment('account_authz.backfill.upsert_batch_failed'); + log.error('backfill.upsert_batch_failed', { + batchNum, + rowCount: upserts.length, + err: errMessage(err), + }); + throw err; + } + } + + totalUpsertsAttempted += upserts.length; + cursor = rows[rows.length - 1].token; + writeCheckpoint(cursor, checkpointFile, log); + + const batchDurationMs = Date.now() - batchStartedMs; + statsd?.timing('account_authz.backfill.batch_duration_ms', batchDurationMs); + statsd?.increment('account_authz.backfill.tokens_scanned', rows.length); + statsd?.increment('account_authz.backfill.batches_completed', { + dry_run: String(dryRun), + }); + + const pct = + estimated > 0 + ? Math.round((totalTokensProcessed / estimated) * 100) + : null; + log.info('backfill.batch', { + batchNum, + tokensProcessed: totalTokensProcessed, + pct, + upsertsAttempted: totalUpsertsAttempted, + skippedAmbiguous: totalSkippedAmbiguous, + skippedNoMatch: totalSkippedNoMatch, + cursor: cursor.toString('hex').slice(0, 8), + durationMs: batchDurationMs, + }); + + if (rows.length < batchSize) break; + + if (batchDelayMs > 0) await sleep(batchDelayMs); + + rows = await withDbRetry('fetchBatch', () => + fetchBatch(query, cursor, opts.endCursor, batchSize) + ); + } + + const totalDurationMs = Date.now() - runStartedMs; + statsd?.timing('account_authz.backfill.run_duration_ms', totalDurationMs); + log.info('backfill.complete', { + tokensScanned: totalTokensProcessed, + upsertsAttempted: totalUpsertsAttempted, + skippedAmbiguous: totalSkippedAmbiguous, + skippedNoMatch: totalSkippedNoMatch, + totalDurationMs, + totalDurationHuman: humanDuration(totalDurationMs), + dryRun, + countsByService, + }); + + if (totalUpsertsAttempted === 0 && !dryRun) { + log.warn('backfill.no_rows_upserted', { + op: 'backfill.complete', + msg: 'No rows were upserted — verify that browserServices clientIds and scopes are correct.', + }); + } +} + +export { + resolveServices, + buildResolvedService, + buildServiceIndexes, + findMatchingServices, + workerCursors, + batchUpsert, + writeCheckpoint, + fetchBatch, + estimateTotalTokens, + parseCursorHex, + withRetry, + run, +}; + +export async function init() { + // require() (not import) so the module's env-driven config validation + // doesn't fire when this file is imported by the spec. + const config = require('../../config').default.getProperties(); + const statsd = new StatsD({ ...config.statsd, maxBufferSize: 0 }); + const log = require('../../lib/log')( + config.log.level, + 'backfill-account-authorizations', + statsd + ); + + program + .version(pckg.version) + .option( + '--dry-run', + 'Log what would be written without writing (recommended first pass)', + false + ) + .option('--batch-size ', 'Rows read per iteration', '1000') + .option( + '--batch-delay-ms ', + 'Sleep between batches in ms (controls DB load)', + '100' + ) + .option( + '--service ', + 'Only backfill a single service (e.g. relay). Omit for all services.' + ) + .option( + '--start-cursor ', + 'Resume from a checkpoint — pass the hex token cursor written to the checkpoint file.' + ) + .option( + '--end-cursor ', + 'Stop at this token (inclusive upper bound). Used for manual range splits.' + ) + .option( + '--worker-count ', + 'Total number of parallel workers. Splits the token keyspace evenly.' + ) + .option( + '--worker-index ', + 'Index of this worker (0-based). Requires --worker-count.' + ) + .option( + '--checkpoint-file ', + 'File to write the cursor after each batch (default: temp/backfill-cursor.txt; overridden by --worker-count)', + './temp/backfill-cursor.txt' + ) + .option( + '--retry-attempts ', + 'Total attempts (incl. first) for transient DB errors before giving up', + '3' + ) + .option( + '--retry-initial-delay-ms ', + 'Initial backoff delay in ms; doubles on each retry (250 → 500 → 1000)', + '250' + ) + .parse(process.argv); + + const dryRun: boolean = program.dryRun; + const batchSize = parseInt(program.batchSize, 10) || 1000; + const batchDelayMs = parseInt(program.batchDelayMs, 10) || 100; + const retryAttempts = parseInt(program.retryAttempts, 10) || 3; + const retryInitialDelayMs = parseInt(program.retryInitialDelayMs, 10) || 250; + const serviceFilter: string | undefined = program.service; + + let startCursor: Buffer; + let endCursor: Buffer | null = null; + let checkpointFile: string = program.checkpointFile; + + if (program.workerCount !== undefined) { + const workerCount = parseInt(program.workerCount, 10); + const workerIndex = parseInt(program.workerIndex ?? '0', 10); + let cursors: { startCursor: Buffer; endCursor: Buffer }; + try { + cursors = workerCursors(workerIndex, workerCount); + } catch (err) { + log.error('backfill.worker_config_error', { err: errMessage(err) }); + return 1; + } + startCursor = cursors.startCursor; + endCursor = cursors.endCursor; + checkpointFile = `./temp/backfill-cursor-worker-${workerIndex}.txt`; + log.info('backfill.worker', { workerIndex, workerCount, checkpointFile }); + } else { + try { + startCursor = program.startCursor + ? parseCursorHex('start-cursor', program.startCursor) + : Buffer.alloc(32); // all zeros — before the first token in sort order + endCursor = program.endCursor + ? parseCursorHex('end-cursor', program.endCursor) + : null; + } catch (err) { + log.error('backfill.cursor_validation_error', { err: errMessage(err) }); + return 1; + } + } + + const dbConfig = config.oauthServer.mysql; + const browserServicesRaw: Record = + config.oauthServer.browserServices ?? {}; + + let services: ResolvedService[]; + let indexes: ServiceIndexes; + try { + services = resolveServices(browserServicesRaw); + if ( + serviceFilter !== undefined && + !services.some((s) => s.name === serviceFilter) + ) { + throw new Error( + `No service '${serviceFilter}' found in browserServices config. ` + + `Available: ${services.map((s) => s.name).join(', ')}` + ); + } + indexes = buildServiceIndexes(services); + } catch (err) { + log.error('backfill.config_error', { err: errMessage(err) }); + return 1; + } + + // connectionLimit: 1 keeps load on the OAuth DB single-threaded (this script + // is sequential by design — see --batch-delay-ms). The pool auto-replaces a + // connection that dies from wait_timeout / network drops, which paired with + // withRetry lets the script ride through transient blips without resuming + // from checkpoint. + const pool = mysql.createPool({ + connectionLimit: 1, + host: dbConfig.host || 'localhost', + port: parseInt(dbConfig.port || '3306', 10), + user: dbConfig.user || 'root', + password: dbConfig.password || '', + database: dbConfig.database || 'fxa_oauth', + timezone: '+00:00', + charset: 'UTF8MB4_UNICODE_CI', + }); + + // Per-connection error events bubble up here when an acquired connection + // dies. The pool will swap in a fresh one for the next query. + pool.on('connection', (conn: mysql.PoolConnection) => { + conn.on('error', (err) => { + statsd?.increment('account_authz.backfill.connection_error', { + code: (err as { code?: string }).code ?? 'unknown', + }); + log.error('backfill.connection_error', { + code: (err as { code?: string }).code, + err: errMessage(err), + msg: 'Connection lost mid-run; pool will acquire a fresh one for the next query.', + }); + }); + }); + + const query = makeQuery(pool); + + try { + await run(query, indexes, log, { + dryRun, + batchSize, + batchDelayMs, + startCursor, + endCursor, + checkpointFile, + serviceFilter, + statsd, + retryAttempts, + retryInitialDelayMs, + }); + } catch (err) { + log.error('backfill.run_failed', { + code: (err as { code?: string }).code, + err: errMessage(err), + msg: 'Run aborted; resume with --start-cursor from the last checkpoint file.', + }); + return 1; + } finally { + await new Promise((resolve) => pool.end(() => resolve())); + } + + return 0; +} + +if (require.main === module) { + let exitStatus = 1; + init() + .then((code) => { + exitStatus = code; + }) + .catch((err) => { + // Fallback to console.error here: init() constructs the structured log + // via require('../../lib/log'), so a crash during config or log + // construction itself leaves us with no log instance to use. + console.error(err); + }) + .finally(() => { + process.exit(exitStatus); + }); +} diff --git a/packages/fxa-auth-server/scripts/backfill-account-authorizations/seed-test-data.ts b/packages/fxa-auth-server/scripts/backfill-account-authorizations/seed-test-data.ts new file mode 100644 index 00000000000..6c8419bf3a1 --- /dev/null +++ b/packages/fxa-auth-server/scripts/backfill-account-authorizations/seed-test-data.ts @@ -0,0 +1,875 @@ +/* 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/. */ + +/** + * Seed test data for the backfill-account-authorizations script. + * + * Inserts deterministic refresh token rows into the local fxa_oauth MySQL DB + * covering all cases in backfill-account-authorizations-test-cases.md. + * + * Usage (from packages/fxa-auth-server): + * node -r esbuild-register scripts/backfill-account-authorizations/seed-test-data.ts + * node -r esbuild-register scripts/backfill-account-authorizations/seed-test-data.ts --volume-count 10000 + * node -r esbuild-register scripts/backfill-account-authorizations/seed-test-data.ts --clean + * node -r esbuild-register scripts/backfill-account-authorizations/seed-test-data.ts --expected + * + * + * All test-case UIDs begin with 0xAA, volume noise UIDs begin with 0xBB. + * --clean removes all rows matching either prefix. + */ + +import crypto from 'crypto'; +import * as mysql from 'mysql'; +import program from 'commander'; + +const config = require('../../config').default.getProperties(); +const browserServices: Record = + config.oauthServer?.browserServices ?? {}; + +const RELAY_SCOPE = 'https://identity.mozilla.com/apps/relay'; +const VPN_SCOPE = 'https://identity.mozilla.com/apps/vpn'; +const PROFILE_SCOPE = 'profile'; +const OPENID_SCOPE = 'openid'; + +// Pick configured clientIds so the strict scope+clientId match logic in the +// backfill (mirroring lib/oauth/browser-services.ts) actually fires. Empty- +// safe defaults here; ensureSeedClientIds() validates on actual seed paths so +// `--expected` and `--clean` can run without a fully-populated config. +const RELAY_CLIENT_IDS = browserServices.relay?.clientIds ?? []; +const VPN_CLIENT_IDS = browserServices.vpn?.clientIds ?? []; +const RELAY_CLIENT_ID = RELAY_CLIENT_IDS[0] ?? ''; +const VPN_CLIENT_ID = VPN_CLIENT_IDS[0] ?? ''; +const MULTI_SERVICE_CLIENT_ID = + VPN_CLIENT_IDS.find((id) => new Set(RELAY_CLIENT_IDS).has(id)) ?? ''; + +function ensureSeedClientIds(): void { + if (!RELAY_CLIENT_ID || !VPN_CLIENT_ID) { + throw new Error( + 'browserServices.relay.clientIds or .vpn.clientIds is empty — check your config' + ); + } + if (!MULTI_SERVICE_CLIENT_ID) { + throw new Error( + 'No clientId is present in both relay and vpn config — check your config' + ); + } +} + +// Client not present in any browserServices.clientIds — used for all +// negative test cases so neither scope nor clientId fallback fires. +const UNRELATED_CLIENT_ID = 'aa01000000000009'; + +// All deterministic test UIDs start with 0xAA (BINARY(16) = 32 hex chars). +const UID_PREFIX = 'aa00000000000000000000000000'; + +function uid(suffix: string): Buffer { + return Buffer.from(`${UID_PREFIX}${suffix}`, 'hex'); +} + +function tokenHash(label: string): Buffer { + return crypto.createHash('sha256').update(`seed-${label}`).digest(); +} + +function hexBuf(hex: string): Buffer { + return Buffer.from(hex, 'hex'); +} + +// Returns a MySQL TIMESTAMP string (UTC). +function toMysqlTimestamp(iso: string): string { + return new Date(iso).toISOString().slice(0, 19).replace('T', ' '); +} + +function toEpochMs(iso: string): number { + return new Date(iso).getTime(); +} + +const DEFAULT_TS = '2025-01-15T10:00:00Z'; + +interface TokenRow { + testId: string; + userId: Buffer; + clientId: Buffer; + scope: string; + token: Buffer; + createdAt: string; + lastUsedAt: string; +} + +interface SeedOpts { + volumeCount: number; + volumeBatchSize: number; + tokensPerUser: number; +} + +interface AuthRow { + uid: Buffer; + scope: string; + service: string; + authorizedAt: number; +} + +function buildTokenRows(): TokenRow[] { + const now = toMysqlTimestamp(DEFAULT_TS); + return [ + // T01 — relay scope + relay clientId (scope+clientId AND match) + { + testId: 'T01', + userId: uid('0001'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t01-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T02 — vpn scope + vpn clientId (scope+clientId AND match) + { + testId: 'T02', + userId: uid('0002'), + clientId: hexBuf(VPN_CLIENT_ID), + scope: VPN_SCOPE, + token: tokenHash('t02-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T03 — relay clientId, no relay scope → single-service clientId fallback fires + { + testId: 'T03', + userId: uid('0003'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: PROFILE_SCOPE, + token: tokenHash('t03-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T04 — profile scope only (no service match) + { + testId: 'T04', + userId: uid('0004'), + clientId: hexBuf(UNRELATED_CLIENT_ID), + scope: PROFILE_SCOPE, + token: tokenHash('t04-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T05 — openid scope only (no service match) + { + testId: 'T05', + userId: uid('0005'), + clientId: hexBuf(UNRELATED_CLIENT_ID), + scope: OPENID_SCOPE, + token: tokenHash('t05-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T06 — profile + openid, no service scope + { + testId: 'T06', + userId: uid('0006'), + clientId: hexBuf(UNRELATED_CLIENT_ID), + scope: `${PROFILE_SCOPE} ${OPENID_SCOPE}`, + token: tokenHash('t06-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T07 — relay+vpn scopes on one token from a clientId minting both + { + testId: 'T07', + userId: uid('0007'), + clientId: hexBuf(MULTI_SERVICE_CLIENT_ID), + scope: `${RELAY_SCOPE} ${VPN_SCOPE}`, + token: tokenHash('t07-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T08 — relay+profile scope, relay clientId (only relay matches) + { + testId: 'T08', + userId: uid('0008'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: `${RELAY_SCOPE} ${PROFILE_SCOPE}`, + token: tokenHash('t08-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T09 — 2 tokens, same uid, relay scope, interleaved timestamps + // Expected: authorizedAt = Jan 1 (LEAST createdAt) + { + testId: 'T09', + userId: uid('0009'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t09-a'), + createdAt: toMysqlTimestamp('2024-01-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-06-01T00:00:00Z'), + }, + { + testId: 'T09', + userId: uid('0009'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t09-b'), + createdAt: toMysqlTimestamp('2024-02-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-12-01T00:00:00Z'), + }, + + // T10 — 2 tokens, same uid, relay scope, identical timestamps + { + testId: 'T10', + userId: uid('000a'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t10-a'), + createdAt: now, + lastUsedAt: now, + }, + { + testId: 'T10', + userId: uid('000a'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t10-b'), + createdAt: now, + lastUsedAt: now, + }, + + // T11 — 3 tokens, relay scope, interleaved across all three + // Token B has earliest createdAt (Jan 1) + // Expected: authorizedAt = Jan 1 (LEAST createdAt) + { + testId: 'T11', + userId: uid('000b'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t11-a'), + createdAt: toMysqlTimestamp('2024-03-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-08-01T00:00:00Z'), + }, + { + testId: 'T11', + userId: uid('000b'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t11-b'), + createdAt: toMysqlTimestamp('2024-01-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-06-01T00:00:00Z'), + }, + { + testId: 'T11', + userId: uid('000b'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t11-c'), + createdAt: toMysqlTimestamp('2024-05-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-12-01T00:00:00Z'), + }, + + // T12 — same uid, separate relay and vpn tokens (expect 2 rows) + { + testId: 'T12', + userId: uid('000c'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t12-a'), + createdAt: now, + lastUsedAt: now, + }, + { + testId: 'T12', + userId: uid('000c'), + clientId: hexBuf(VPN_CLIENT_ID), + scope: VPN_SCOPE, + token: tokenHash('t12-b'), + createdAt: now, + lastUsedAt: now, + }, + + // T13 — same uid, relay + profile tokens (only 1 row for relay) + { + testId: 'T13', + userId: uid('000d'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t13-a'), + createdAt: now, + lastUsedAt: now, + }, + { + testId: 'T13', + userId: uid('000d'), + clientId: hexBuf(UNRELATED_CLIENT_ID), + scope: PROFILE_SCOPE, + token: tokenHash('t13-b'), + createdAt: now, + lastUsedAt: now, + }, + + // T14 — relay-extra scope — partial match must NOT fire + { + testId: 'T14', + userId: uid('000e'), + clientId: hexBuf(UNRELATED_CLIENT_ID), + scope: `${RELAY_SCOPE}-extra`, + token: tokenHash('t14-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T15 — relay scope prefix — must NOT match + { + testId: 'T15', + userId: uid('000f'), + clientId: hexBuf(UNRELATED_CLIENT_ID), + scope: 'https://identity.mozilla.com/apps/rela', + token: tokenHash('t15-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T16 — relay is second in space-separated scope string (must still match) + { + testId: 'T16', + userId: uid('0010'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: `${PROFILE_SCOPE} ${RELAY_SCOPE}`, + token: tokenHash('t16-a'), + createdAt: now, + lastUsedAt: now, + }, + + // T17 — specific timestamps for BIGINT conversion verification + // Expected authorizedAt: toEpochMs('2025-01-15T10:00:00Z') = 1736935200000 + { + testId: 'T17', + userId: uid('0011'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t17-a'), + createdAt: toMysqlTimestamp('2025-01-15T10:00:00Z'), + lastUsedAt: toMysqlTimestamp('2025-06-01T14:00:00Z'), + }, + + // T18 — idempotency: token createdAt (Jan 2024) matches pre-seeded authorizedAt (Jan 2024) — no-op + { + testId: 'T18', + userId: uid('0012'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t18-a'), + createdAt: toMysqlTimestamp('2024-01-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-12-01T00:00:00Z'), + }, + + // T19 — idempotency: token createdAt (Jan 2024) matches pre-seeded authorizedAt (Jan 2024) — no-op + { + testId: 'T19', + userId: uid('0013'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t19-a'), + createdAt: toMysqlTimestamp('2024-01-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-06-01T00:00:00Z'), + }, + + // T20 — idempotency: token createdAt (Jun 2023) is earlier than pre-seeded authorizedAt (Jan 2024) + // Expected: authorizedAt updates to Jun 2023 (LEAST wins) + { + testId: 'T20', + userId: uid('0014'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t20-a'), + createdAt: toMysqlTimestamp('2023-06-01T00:00:00Z'), + lastUsedAt: toMysqlTimestamp('2024-06-01T00:00:00Z'), + }, + + // T21 — idempotency: identical data, run script twice — result is a no-op + { + testId: 'T21', + userId: uid('0015'), + clientId: hexBuf(RELAY_CLIENT_ID), + scope: RELAY_SCOPE, + token: tokenHash('t21-a'), + createdAt: now, + lastUsedAt: now, + }, + + // Unrelated client with non-service scope — should produce 0 rows + { + testId: 'noise-client', + userId: uid('0099'), + clientId: hexBuf(UNRELATED_CLIENT_ID), + scope: PROFILE_SCOPE, + token: tokenHash('noise-client-a'), + createdAt: now, + lastUsedAt: now, + }, + ]; +} + +function buildPreExistingAuths(): AuthRow[] { + return [ + // T18: row already exists — second run is a no-op (same authorizedAt) + { + uid: uid('0012'), + scope: RELAY_SCOPE, + service: 'relay', + authorizedAt: toEpochMs('2024-01-01T00:00:00Z'), + }, + // T19: row already exists — second run is a no-op (same authorizedAt) + { + uid: uid('0013'), + scope: RELAY_SCOPE, + service: 'relay', + authorizedAt: toEpochMs('2024-01-01T00:00:00Z'), + }, + // T20: row has later authorizedAt — LEAST should update it to token.createdAt (Jun 2023) + { + uid: uid('0014'), + scope: RELAY_SCOPE, + service: 'relay', + authorizedAt: toEpochMs('2024-01-01T00:00:00Z'), + }, + ]; +} + +function printExpectedOutcomes(): void { + const t17AuthorizedAt = toEpochMs('2025-01-15T10:00:00Z'); + + const rows = [ + { + id: 'T01', + uid: 'aa...0001', + rows: 1, + note: `service=relay (scope match)`, + }, + { + id: 'T02', + uid: 'aa...0002', + rows: 1, + note: `service=vpn (scope match)`, + }, + { + id: 'T03', + uid: 'aa...0003', + rows: 1, + note: `service=relay (clientId match — requires browserServices config)`, + }, + { id: 'T04', uid: 'aa...0004', rows: 0, note: `profile scope — no match` }, + { id: 'T05', uid: 'aa...0005', rows: 0, note: `openid scope — no match` }, + { id: 'T06', uid: 'aa...0006', rows: 0, note: `profile+openid — no match` }, + { + id: 'T07', + uid: 'aa...0007', + rows: 2, + note: `service=relay + service=vpn (both scopes on one token)`, + }, + { + id: 'T08', + uid: 'aa...0008', + rows: 1, + note: `service=relay only (profile is ignored)`, + }, + { + id: 'T09', + uid: 'aa...0009', + rows: 1, + note: `service=relay authorizedAt=${toEpochMs('2024-01-01T00:00:00Z')} (LEAST createdAt)`, + }, + { + id: 'T10', + uid: 'aa...000a', + rows: 1, + note: `service=relay deduped identical timestamps`, + }, + { + id: 'T11', + uid: 'aa...000b', + rows: 1, + note: `service=relay authorizedAt=${toEpochMs('2024-01-01T00:00:00Z')} (LEAST createdAt across 3 tokens)`, + }, + { + id: 'T12', + uid: 'aa...000c', + rows: 2, + note: `service=relay + service=vpn (separate tokens)`, + }, + { + id: 'T13', + uid: 'aa...000d', + rows: 1, + note: `service=relay only (profile token is noise)`, + }, + { + id: 'T14', + uid: 'aa...000e', + rows: 0, + note: `relay-extra scope — must NOT match`, + }, + { + id: 'T15', + uid: 'aa...000f', + rows: 0, + note: `scope prefix 'rela' — must NOT match`, + }, + { + id: 'T16', + uid: 'aa...0010', + rows: 1, + note: `service=relay (relay is second in scope string)`, + }, + { + id: 'T17', + uid: 'aa...0011', + rows: 1, + note: `service=relay authorizedAt=${t17AuthorizedAt} (BIGINT conversion)`, + }, + { + id: 'T18', + uid: 'aa...0012', + rows: 1, + note: `service=relay authorizedAt unchanged (pre-seeded row, same value — no-op)`, + }, + { + id: 'T19', + uid: 'aa...0013', + rows: 1, + note: `service=relay authorizedAt unchanged (pre-seeded row, same value — no-op)`, + }, + { + id: 'T20', + uid: 'aa...0014', + rows: 1, + note: `service=relay authorizedAt updated to ${toEpochMs('2023-06-01T00:00:00Z')} (LEAST)`, + }, + { + id: 'T21', + uid: 'aa...0015', + rows: 1, + note: `service=relay second run is a no-op`, + }, + { + id: 'noise-client', + uid: 'aa...0099', + rows: 0, + note: `unrelated clientId — must produce 0 rows`, + }, + ]; + + console.log( + '\n=== Expected accountAuthorizations after running backfill ===\n' + ); + console.log('ID UID Rows Note'); + console.log('─'.repeat(90)); + for (const r of rows) { + console.log( + `${r.id.padEnd(14)}${r.uid.padEnd(16)}${String(r.rows).padEnd(6)}${r.note}` + ); + } + + console.log( + '\nT25/T26 volume: service-scope tokens → rows in accountAuthorizations; non-service-scope tokens → 0 rows' + ); + console.log( + 'T24 resumability: seeded rows are deterministic — run with --start-cursor to pick up mid-way' + ); + console.log( + 'T27–T30 service filter: covered by the data above; pass --service relay to the backfill script' + ); +} + +// 10 slots cycled by `i % 10` so distribution is deterministic. Under strict +// matching (scope+clientId AND), pairing each scope with a clientId that mints +// it is what causes the backfill to produce a row. ~30% should match. +const VOLUME_VARIANTS: Array<{ scope: string; clientId: string }> = [ + // 3/10 produce rows + { scope: RELAY_SCOPE, clientId: RELAY_CLIENT_ID }, // → relay + { scope: VPN_SCOPE, clientId: VPN_CLIENT_ID }, // → vpn + { scope: `${RELAY_SCOPE} ${VPN_SCOPE}`, clientId: MULTI_SERVICE_CLIENT_ID }, // → relay + vpn + // 7/10 produce 0 rows: non-service scope OR scope without clientId membership + { scope: PROFILE_SCOPE, clientId: UNRELATED_CLIENT_ID }, + { scope: OPENID_SCOPE, clientId: UNRELATED_CLIENT_ID }, + { scope: `${PROFILE_SCOPE} ${OPENID_SCOPE}`, clientId: UNRELATED_CLIENT_ID }, + { scope: 'https://example.com/other', clientId: UNRELATED_CLIENT_ID }, + { scope: RELAY_SCOPE, clientId: UNRELATED_CLIENT_ID }, // scope-only — must NOT fire + { scope: VPN_SCOPE, clientId: UNRELATED_CLIENT_ID }, // scope-only — must NOT fire + { scope: PROFILE_SCOPE, clientId: UNRELATED_CLIENT_ID }, +]; + +// Little-endian layout means byte[0] = i & 0xff cycles through 0–255 every 256 +// rows, giving uniform first-byte distribution matching real SHA-256 tokens. +function volumeToken(i: number): Buffer { + const token = Buffer.alloc(32, 0); + token.writeUInt32LE(i >>> 0, 0); + token.writeUInt32LE(Math.floor(i / 0x100000000) >>> 0, 4); + return token; +} + +// UIDs use the 0xBB prefix so --clean can delete them in bulk. +function volumeUserId(i: number, tokensPerUser: number): Buffer { + const userIndex = Math.floor(i / tokensPerUser); + const userId = Buffer.alloc(16, 0); + userId[0] = 0xbb; + userId.writeUInt32LE(userIndex >>> 0, 1); + userId.writeUInt32LE(Math.floor(userIndex / 0x100000000) >>> 0, 5); + return userId; +} + +function makeQuery(conn: mysql.Connection) { + return (sql: string, values?: any[]): Promise => + new Promise((resolve, reject) => { + conn.query(sql, values, (err, results) => { + if (err) reject(err); + else resolve(results); + }); + }); +} + +async function tableExists( + query: ReturnType, + tableName: string +): Promise { + const rows = await query( + `SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`, + [tableName] + ); + return rows[0].cnt > 0; +} + +async function seedVolume( + query: ReturnType, + totalCount: number, + batchSize: number, + tokensPerUser: number +): Promise { + const now = toMysqlTimestamp(DEFAULT_TS); + const variantBuffers = VOLUME_VARIANTS.map((v) => ({ + scope: v.scope, + clientId: hexBuf(v.clientId), + })); + const INSERT_SQL = + 'INSERT INTO refreshTokens (clientId, userId, scope, token, profileChangedAt, createdAt, lastUsedAt) ' + + 'VALUES ? ON DUPLICATE KEY UPDATE scope = scope'; + + console.log( + `\nSeeding ${totalCount.toLocaleString()} volume rows ` + + `(batch=${batchSize}, tokensPerUser=${tokensPerUser})...` + ); + + await query('SET unique_checks = 0, foreign_key_checks = 0'); + + const startMs = Date.now(); + let inserted = 0; + + try { + while (inserted < totalCount) { + const end = Math.min(inserted + batchSize, totalCount); + const rows: any[][] = []; + + for (let i = inserted; i < end; i++) { + const variant = variantBuffers[i % variantBuffers.length]; + rows.push([ + variant.clientId, + volumeUserId(i, tokensPerUser), + variant.scope, + volumeToken(i), + null, + now, + now, + ]); + } + + await query(INSERT_SQL, [rows]); + inserted = end; + + const elapsed = (Date.now() - startMs) / 1000; + const rate = Math.round(inserted / elapsed); + const remaining = totalCount - inserted; + const etaSec = rate > 0 ? Math.round(remaining / rate) : 0; + process.stdout.write( + `\r ${inserted.toLocaleString()} / ${totalCount.toLocaleString()} ` + + `(${rate.toLocaleString()} rows/s, ETA ${etaSec}s) ` + ); + } + } finally { + // Restore session settings so a partial run doesn't leave the connection + // in a relaxed-integrity mode for any subsequent statement. + await query('SET unique_checks = 1, foreign_key_checks = 1'); + } + const totalSec = ((Date.now() - startMs) / 1000).toFixed(1); + console.log(`\n✓ Volume rows seeded in ${totalSec}s`); +} + +async function seed( + query: ReturnType, + opts: SeedOpts +): Promise { + ensureSeedClientIds(); + + const INSERT_TOKEN = + 'INSERT INTO refreshTokens (clientId, userId, scope, token, profileChangedAt, createdAt, lastUsedAt) ' + + 'VALUES (?, ?, ?, ?, NULL, ?, ?) ON DUPLICATE KEY UPDATE scope = scope'; + + const tokenRows = buildTokenRows(); + console.log( + `Seeding ${tokenRows.length} deterministic token rows (T01–T21 + noise-client)...` + ); + + for (const r of tokenRows) { + await query(INSERT_TOKEN, [ + r.clientId, + r.userId, + r.scope, + r.token, + r.createdAt, + r.lastUsedAt, + ]); + process.stdout.write('.'); + } + console.log(`\n✓ Token rows seeded`); + + const hasAuthTable = await tableExists(query, 'accountAuthorizations'); + if (hasAuthTable) { + const INSERT_AUTH = + 'INSERT INTO accountAuthorizations (uid, scope, service, authorizedAt) ' + + 'VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE authorizedAt = authorizedAt'; + + const authRows = buildPreExistingAuths(); + console.log( + `\nSeeding ${authRows.length} pre-existing accountAuthorizations rows (T18–T20)...` + ); + for (const r of authRows) { + await query(INSERT_AUTH, [r.uid, r.scope, r.service, r.authorizedAt]); + process.stdout.write('.'); + } + console.log(`\n✓ Pre-existing auth rows seeded`); + } else { + console.log( + '\n⚠ accountAuthorizations table not found — skipping T18–T20 pre-seed.' + ); + console.log( + ' Run the FXA-12931 migration first if you want to test idempotency cases.' + ); + } + + if (opts.volumeCount > 0) { + await seedVolume( + query, + opts.volumeCount, + opts.volumeBatchSize, + opts.tokensPerUser + ); + } + + console.log('\n✓ Seed complete.'); + printExpectedOutcomes(); +} + +async function clean(query: ReturnType): Promise { + // 0xAA prefix = deterministic test case rows + // 0xBB prefix = volume noise rows + const { affectedRows: tokenRows } = await query( + "DELETE FROM refreshTokens WHERE LEFT(userId, 1) IN (UNHEX('aa'), UNHEX('bb'))" + ); + console.log(`✓ Deleted ${tokenRows} rows from refreshTokens`); + + const hasAuthTable = await tableExists(query, 'accountAuthorizations'); + if (hasAuthTable) { + // Volume noise tokens (0xBB) can produce accountAuthorizations rows when + // they pair multi-service clientIds with service scopes. Delete both + // prefixes so a stale row from a previous run can't bleed into the next. + const { affectedRows: authRows } = await query( + "DELETE FROM accountAuthorizations WHERE LEFT(uid, 1) IN (UNHEX('aa'), UNHEX('bb'))" + ); + console.log(`✓ Deleted ${authRows} rows from accountAuthorizations`); + } + + console.log('✓ Clean complete.'); +} + +export async function init() { + const config = require('../../config').default.getProperties(); + const dbConfig = config.oauthServer.mysql; + + program + .option('--clean', 'Remove all seeded test data instead of inserting') + .option( + '--expected', + 'Print expected accountAuthorizations outcomes and exit' + ) + .option( + '--volume-count ', + 'Number of volume noise rows to seed (0 to skip)', + '10000' + ) + .option( + '--volume-batch-size ', + 'Rows per INSERT for volume data (larger = faster)', + '5000' + ) + .option( + '--tokens-per-user ', + 'Volume tokens sharing one UID (1 = one token per account)', + '1' + ) + .parse(process.argv); + + if (program.expected) { + printExpectedOutcomes(); + return 0; + } + + const conn = mysql.createConnection({ + host: dbConfig.host || 'localhost', + port: parseInt(dbConfig.port || '3306', 10), + user: dbConfig.user || 'root', + password: dbConfig.password || '', + database: dbConfig.database || 'fxa_oauth', + timezone: '+00:00', + charset: 'UTF8MB4_UNICODE_CI', + }); + + await new Promise((resolve, reject) => { + conn.connect((err) => (err ? reject(err) : resolve())); + }); + + const query = makeQuery(conn); + + try { + if (program.clean) { + await clean(query); + } else { + await seed(query, { + volumeCount: parseInt(program.volumeCount, 10) || 0, + volumeBatchSize: parseInt(program.volumeBatchSize, 10) || 5000, + tokensPerUser: parseInt(program.tokensPerUser, 10) || 1, + }); + } + } finally { + conn.end(); + } + + return 0; +} + +if (require.main === module) { + let exitStatus = 1; + init() + .then((code) => { + exitStatus = code; + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + process.exit(exitStatus); + }); +}