Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/push.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ norbix push integration confirm-delivery int_123
| `norbix push device register --user <id> --token <t> --os <os>` | register a device for a user | `POST /devices` |
| `norbix push devices` | list the registered devices | `GET /devices` |
| `norbix push device <id>` | show one device and its owner | `GET /devices/{id}` |
| `norbix push device delete <id>` | 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
Expand All @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions src/commands/logs/settings.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
const {flags} = await this.parse(LogsSettings)
Expand All @@ -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
}
}
77 changes: 77 additions & 0 deletions src/commands/push/device/delete.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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<string, string> = {
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}
}
}
100 changes: 100 additions & 0 deletions test/push-device-delete.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

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<string, string> = {}
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<string, string>}}>([
'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)
})
})
13 changes: 13 additions & 0 deletions tests/__snapshots__/schema.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions tests/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
Loading