From 9ac7380ad47de1e7f0ed1acad37f9d753bc0bb08 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 22 Oct 2026 16:27:54 +0300 Subject: [PATCH] feat(push,logs): push device delete (admin, gateway N48); logs settings prints the env's storage warning - `norbix push device delete ` calls the Hub's new admin route DELETE /v3/notifications/push/devices/{Id} directly (the SDK does not know it yet), in the --env environment; asks first or --yes; --dry-run; a 405 names an old hub. - `norbix logs settings` describes the per-env self-check (env, lastEntryAtUtc, lastStoredAtUtc, storageWarning) and writes the warning to stderr. Co-Authored-By: Claude Opus 5.5 --- docs/push.md | 11 +++ src/commands/logs/settings.ts | 25 +++++- src/commands/push/device/delete.ts | 77 ++++++++++++++++++ test/push-device-delete.test.ts | 100 ++++++++++++++++++++++++ tests/__snapshots__/schema.test.ts.snap | 13 +++ tests/logs.test.ts | 18 +++++ 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/commands/push/device/delete.ts create mode 100644 test/push-device-delete.test.ts diff --git a/docs/push.md b/docs/push.md index 4ff1933..61ddf0d 100644 --- a/docs/push.md +++ b/docs/push.md @@ -54,6 +54,7 @@ norbix push integration confirm-delivery int_123 | `norbix push device register --user --token --os ` | register a device for a user | `POST /devices` | | `norbix push devices` | list the registered devices | `GET /devices` | | `norbix push device ` | show one device and its owner | `GET /devices/{id}` | +| `norbix push device delete ` | delete any user's device (admin, `push:delete`); asks first, or `--yes` | `DELETE /devices/{Id}` | A device always belongs to a user, so `--user` is required on register. `--os` is the operating system the token came from; the backend uses it to pick a @@ -72,6 +73,16 @@ norbix push devices --platform ios norbix push device pnd_123 ``` +`push device delete` removes a device from whichever user holds it — a stale +handset, or a test device of another user. It works in the `--env` environment +(the device must be there), needs a hub with gateway N48 (an older hub answers +405 and the command says so), and calls the route directly until the SDK knows +it. A user removes their own device from the app, through the data API. + +```bash +norbix push device delete pnd_123 --env TEST --yes +``` + Devices are stored inside their user, so a page is a page of **users** and carries every matching device those users hold. Follow `hasMore` rather than stopping at the first short page. diff --git a/src/commands/logs/settings.ts b/src/commands/logs/settings.ts index 959db3f..f9e93d8 100644 --- a/src/commands/logs/settings.ts +++ b/src/commands/logs/settings.ts @@ -1,9 +1,24 @@ import {BaseCommand} from '../../base.js' +/** The storage self-check fields of `GET /logs/settings` (gateway N48). */ +interface LogSettingsSelfCheck { + env?: string + hasNorbixLogging?: boolean + lastEntryAtUtc?: string + lastStoredAtUtc?: string + storageWarning?: string +} + export default class LogsSettings extends BaseCommand { - static description = 'Show project log settings' + static description = `Show project log settings for the --env environment + +Each environment (PROD, TEST, …) has its own Norbix Logging integration. Besides +the flags, a hub with the N48 fix answers a storage self-check: when an entry of +this environment last reached the log writer (lastEntryAtUtc), when its Norbix +Logging integration last stored one (lastStoredAtUtc), and a warning when its +logs are not being stored.` - static examples = ['<%= config.bin %> logs settings'] + static examples = ['<%= config.bin %> logs settings', '<%= config.bin %> logs settings --env TEST'] async run(): Promise { const {flags} = await this.parse(LogsSettings) @@ -13,6 +28,12 @@ export default class LogsSettings extends BaseCommand { }) this.print(res) + + const check = res as LogSettingsSelfCheck + if (check.storageWarning && !this.jsonEnabled()) { + process.stderr.write(`Warning: ${check.storageWarning}\n`) + } + return res } } diff --git a/src/commands/push/device/delete.ts b/src/commands/push/device/delete.ts new file mode 100644 index 0000000..474df04 --- /dev/null +++ b/src/commands/push/device/delete.ts @@ -0,0 +1,77 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' + +export default class PushDeviceDelete extends BaseCommand { + static description = `Delete a registered push device, whichever user it belongs to (admin) + +Removes the device from its user, so it gets no more push notifications. Needs +the right to delete push devices (push:delete on push:device:all). Works in the +--env environment, on that environment's users. Find the id with +\`norbix push devices\`. A user removes their own device from the app instead.` + + static examples = [ + '<%= config.bin %> push device delete pnd_123 --env TEST --yes', + '<%= config.bin %> push device delete pnd_123 --dry-run', + ] + + static args = { + id: Args.string({required: true, description: 'Device ID (pnd_…)'}), + } + + static flags = { + ...BaseCommand.mutatingFlags, + 'database-integration': Flags.string({description: "Database integration ID; default: the project's default for the environment"}), + } + + async run(): Promise { + const {args, flags} = await this.parse(PushDeviceDelete) + const ctx = await this.freshContext(flags) + this.assertEndpoints(ctx) + + if (!ctx.projectId) this.error('No project ID configured. Run `norbix login` or pass --project.') + const token = ctx.bearerToken ?? ctx.apiKey + if (!token) this.error('Not authenticated. Run `norbix login` or pass --api-key.') + + // Hub route added by gateway N48; the SDK does not know it yet, so it is + // called directly (same headers the SDK transport sends). + const url = new URL(`${ctx.hubUrl.replace(/\/$/, '')}/v3/notifications/push/devices/${encodeURIComponent(args.id)}`) + if (flags['database-integration']) url.searchParams.set('databaseIntegrationId', flags['database-integration']) + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + 'norbix-project-id': ctx.projectId, + } + if (ctx.accountId) headers['norbix-account-id'] = ctx.accountId + if (ctx.env && ctx.env !== 'PROD') headers['norbix-env'] = ctx.env + if (ctx.region) headers['nb-region'] = ctx.region + + if (flags['dry-run']) { + return this.dryRun({ + method: 'push device delete', + request: {id: args.id}, + http: {method: 'DELETE', url: url.href, headers: {...headers, Authorization: 'Bearer ***'}}, + }) + } + + await this.confirmOrFail(`Delete push device ${args.id}${ctx.env ? ` in ${ctx.env}` : ''}?`, flags) + + const res = await fetch(url, {method: 'DELETE', headers}) + const text = await res.text() + let answer: {responseStatus?: {isSuccess?: boolean; errors?: Array<{message?: string; errorCode?: string}>}} = {} + try { + answer = text ? JSON.parse(text) : {} + } catch { + this.error(`HTTP ${res.status}: ${text.slice(0, 300)}`) + } + + if (!res.ok || answer.responseStatus?.isSuccess === false) { + const why = answer.responseStatus?.errors?.map((e) => [e.errorCode, e.message].filter(Boolean).join(' ')).join('; ') + const hint = res.status === 405 ? ' (this hub is older than the admin device delete — upgrade it)' : '' + this.error(`HTTP ${res.status}${why ? `: ${why}` : ''}${hint}`) + } + + this.print(`Push device ${args.id} deleted.`) + return {id: args.id, deleted: true} + } +} diff --git a/test/push-device-delete.test.ts b/test/push-device-delete.test.ts new file mode 100644 index 0000000..5c4d649 --- /dev/null +++ b/test/push-device-delete.test.ts @@ -0,0 +1,100 @@ +import {runCommand} from '@oclif/test' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from '../tests/_env.js' + +/** + * `norbix push device delete` (gateway N48): the Hub's admin route + * `DELETE /v3/notifications/push/devices/{Id}` — any user's device, in the + * --env environment. The real command runs through oclif with `fetch` replaced. + */ + +const PROJECT = 'test-project' +const API_KEY = 'test-api-key' +const REGION = 'nb-eu-germany' +const DEVICE = 'pnd_7IEb8WjfRcwer3LPwl7fd6' +const globalArgs = ['--project', PROJECT, '--api-key', API_KEY, '--region', REGION] + +interface Call { + method: string + url: string + headers: Record +} + +let calls: Call[] +let realFetch: typeof globalThis.fetch + +function fakeFetch(status: number, body: unknown): void { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init, ['PROD', 'TEST']) + if (envList) return envList + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const headers: Record = {} + new Headers(init?.headers).forEach((v, k) => (headers[k] = v)) + calls.push({method: init?.method ?? 'GET', url, headers}) + return new Response(JSON.stringify(body), {status, headers: {'Content-Type': 'application/json'}}) + }) as typeof globalThis.fetch +} + +const devicesCalls = () => calls.filter((c) => c.url.includes('/notifications/push/devices/')) + +beforeEach(() => { + calls = [] + realFetch = globalThis.fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +describe('norbix push device delete', () => { + it('calls the hub admin route in the env', async () => { + fakeFetch(200, {responseStatus: {isSuccess: true, errors: []}}) + + const {result, error} = await runCommand<{id: string; deleted: boolean}>([ + 'push', 'device', 'delete', DEVICE, '--env', 'TEST', '--yes', ...globalArgs, + ]) + + expect(error).toBeUndefined() + const call = devicesCalls()[0]! + expect(call.method).toBe('DELETE') + expect(new URL(call.url).host).toMatch(/(^|\.)hub\./) + expect(new URL(call.url).pathname).toBe(`/v3/notifications/push/devices/${DEVICE}`) + expect(call.headers['norbix-env']).toBe('TEST') + expect(call.headers['norbix-project-id']).toBe(PROJECT) + expect(result).toEqual({id: DEVICE, deleted: true}) + }) + + it('says why the hub refused', async () => { + fakeFetch(403, {responseStatus: {isSuccess: false, errors: [ + {errorCode: 'CM-ERRORS-MEMBERSHIP-039', message: "Caller is missing required permission 'push:delete on …:push:device:all'."}, + ]}}) + + const {error} = await runCommand(['push', 'device', 'delete', DEVICE, '--yes', ...globalArgs]) + + expect(error?.message).toContain('HTTP 403') + expect(error?.message).toContain('push:delete') + }) + + it('names an old hub on 405', async () => { + fakeFetch(405, {}) + + const {error} = await runCommand(['push', 'device', 'delete', DEVICE, '--yes', ...globalArgs]) + + expect(error?.message).toContain('older than the admin device delete') + }) + + it('sends nothing on --dry-run', async () => { + fakeFetch(200, {}) + + const {result, error} = await runCommand<{dryRun: boolean; http: {method: string; url: string; headers: Record}}>([ + 'push', 'device', 'delete', DEVICE, '--env', 'TEST', '--dry-run', ...globalArgs, + ]) + + expect(error).toBeUndefined() + expect(result?.dryRun).toBe(true) + expect(result?.http.method).toBe('DELETE') + expect(result?.http.url).toContain(`/v3/notifications/push/devices/${DEVICE}`) + expect(result?.http.headers.Authorization).toBe('Bearer ***') + expect(devicesCalls()).toHaveLength(0) + }) +}) diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index 8f608e8..ab76cc5 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -2943,6 +2943,19 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "push device", "supportsDryRun": false, }, + { + "args": [ + "id", + ], + "destructive": true, + "flags": [ + "yes:boolean", + "dry-run:boolean", + "database-integration:string", + ], + "id": "push device delete", + "supportsDryRun": true, + }, { "args": [], "destructive": false, diff --git a/tests/logs.test.ts b/tests/logs.test.ts index 04f9a99..5be07ac 100644 --- a/tests/logs.test.ts +++ b/tests/logs.test.ts @@ -171,6 +171,24 @@ describe('logs settings', () => { }) }) +describe('logs settings — N48 self-check', () => { + it('prints the storage warning of the env in plain words', async () => { + answer = {status: 200, body: { + env: 'TEST', hasNorbixLogging: false, + storageWarning: 'TEST has no Norbix Logging integration, so TEST log entries (and scheduler runs) are not stored.', + }} + const warned = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const {error, stderr} = await run(['settings', '--env', 'TEST']) + + expect(error).toBeUndefined() + expect(out()).toContain('TEST') + const said = stderr + warned.mock.calls.map((c) => String(c[0])).join('') + expect(said).toContain('TEST has no Norbix Logging integration') + warned.mockRestore() + }) +}) + describe('logs settings set', () => { it('sends only the setting given (absent = unchanged)', async () => { const {error} = await run(['settings', 'set', '--skip-body', 'true'])