Merge pull request #299 from vernu/fix/os-build-fingerprint

Keep the build string a current client reports
This commit is contained in:
vernu
2026-08-08 11:09:02 +03:00
committed by GitHub
3 changed files with 99 additions and 1 deletions
+53
View File
@@ -691,6 +691,59 @@ describe('GatewayService', () => {
expect(update.$set.os).toBe('Android')
})
it('persists the build string a current client reports', async () => {
// `os` is the plain 'Android' label now, so the build string arrives in
// its own field. If the normalizer ignores it the field becomes writable
// only by the backfill script and no app build can ever populate it.
const fingerprint =
'samsung/e3qxxx/e3q:16/BP2A.250605.031.A3/S928BXXU4CYI7:user/release-keys'
mockDeviceModel.findById.mockResolvedValue(mockDevice)
mockDeviceModel.findByIdAndUpdate.mockResolvedValue(mockDevice)
await service.updateDevice(mockDeviceId, {
...mockDeviceInput,
os: 'Android',
osVersion: '16',
osApiLevel: 36,
osBuildFingerprint: fingerprint,
} as RegisterDeviceInputDTO)
const [, update] = mockDeviceModel.findByIdAndUpdate.mock.calls[0]
expect(update.$set).toMatchObject({
os: 'Android',
osVersion: '16',
osApiLevel: 36,
osBuildFingerprint: fingerprint,
})
})
it.each([
['osVersion', { osVersion: '' }],
['osApiLevel', { osApiLevel: null }],
['osBuildFingerprint', { osBuildFingerprint: '' }],
])(
'must not blank a stored %s sent empty by a non-first-party client',
async (field, payload) => {
// The gateway API is public and has no ValidationPipe, so these can
// arrive from any client, not just the Android app.
mockDeviceModel.findById.mockResolvedValue({
...mockDevice,
osVersion: '14',
osApiLevel: 34,
osBuildFingerprint: 'samsung/a13nnxx/a13:14/UP1A.231005.007/x:user/release-keys',
})
mockDeviceModel.findByIdAndUpdate.mockResolvedValue(mockDevice)
await service.updateDevice(mockDeviceId, {
...mockDeviceInput,
...payload,
} as RegisterDeviceInputDTO)
const [, update] = mockDeviceModel.findByIdAndUpdate.mock.calls[0]
expect(update.$set).not.toHaveProperty(field)
},
)
it('should ignore a client-sent isDefault', async () => {
// set-default is the only route allowed to move the default flag
mockDeviceModel.findById.mockResolvedValue(mockDevice)
+37
View File
@@ -83,6 +83,43 @@ describe('normalizeOsFields', () => {
osApiLevel: 36,
})
})
// Current clients put the build string in its own field, because `os` now
// carries the plain 'Android' label and so has no '/' to parse. Without
// this the field is only ever writable by the backfill script.
it('keeps the build string reported in its own field', () => {
expect(
normalizeOsFields({
os: 'Android',
osVersion: '16',
osApiLevel: 36,
osBuildFingerprint: FP_16,
}),
).toEqual({
osVersion: '16',
osApiLevel: 36,
osBuildFingerprint: FP_16,
os: 'Android',
})
})
it('prefers the reported build string over one parsed out of os', () => {
const patch = normalizeOsFields({ os: FP_14, osBuildFingerprint: FP_16 })
expect(patch.osBuildFingerprint).toBe(FP_16)
})
it('emits no build string when the reported one is blank', () => {
// BASE_OS is empty on many devices, and '' is not null, so it would
// otherwise reach $set.
for (const blank of ['', ' ']) {
const patch = normalizeOsFields({ os: 'Android', osBuildFingerprint: blank })
expect(patch).not.toHaveProperty('osBuildFingerprint')
}
})
it('falls back to os when no build string is reported', () => {
expect(normalizeOsFields({ os: FP_14 }).osBuildFingerprint).toBe(FP_14)
})
})
describe('partial payloads', () => {
+9 -1
View File
@@ -45,7 +45,15 @@ export function normalizeOsFields(input: OsFieldsInput): Record<string, any> {
patch.osApiLevel = input.osApiLevel
}
if (raw && raw.includes('/')) patch.osBuildFingerprint = raw
// Current clients send the build string in its own field, since `os` now
// carries the plain 'Android' label. Older clients only ever put it in `os`.
const reportedFingerprint = input?.osBuildFingerprint?.trim()
if (reportedFingerprint) {
patch.osBuildFingerprint = reportedFingerprint
} else if (raw && raw.includes('/')) {
patch.osBuildFingerprint = raw
}
if (input?.os !== undefined) patch.os = 'Android'
return patch