fix(usage): isolate the Postgres fixture, and stop two more wrong signals

Three findings, one of them mine and CI-affecting.

The Postgres suite gets its own schema. CI hands every gated suite the
same PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both
picpeakRestorePg and externalRelpathFoldPg drop and recreate `events`
and `app_settings` in it — so the suite I added would have destroyed
their fixtures and vice versa, intermittently. It now creates and drops
its own `usage_pg_test` schema and reaches the tables through
searchPath, which works because the service queries unqualified names.
Verified on a clean database: after the run `public` still holds zero
tables. My first attempt at this silently did not apply — the
replacement anchor had been reformatted by eslint and I printed success
without asserting the match, which is why the first "isolated" claim was
wrong.

Webhook-only installs are no longer counted as SMTP users. With
EMAIL_WEBHOOK_URL and EMAIL_WEBHOOK_SECRET set, adminEmail sends
/email/test through the webhook transport and never touches SMTP (#1225
added that path), but the rule recorded the permanent `smtp` marker
anyway. Gated on the transport that is actually configured.

Activation is written atomically with its acknowledgement. Split across
two updates, a failure or a stop between them left the row
activation_pending with pending_packet already cleared — registered with
the collector, and permanently stuck locally, because tick() has nothing
to retry from there. The register case now sets status in the same write
and is guarded precisely on activation_pending rather than merely "not
withdrawing".

Refs #1110
This commit is contained in:
Paul Nothaft
2026-09-05 23:26:41 +02:00
parent 32d745b575
commit cc263f2e87
3 changed files with 54 additions and 16 deletions
+17 -8
View File
@@ -474,17 +474,26 @@ class UsageService {
update.last_packet = JSON.stringify(envelope);
update.last_report_date = packet.payload.report_date;
}
await this.db('product_usage_state')
.where({ id: 1 })
.whereNot({ status: 'deletion_pending' })
.update(update);
// Activation goes in with the acknowledgement, not after it. Split
// across two writes, a failure or a stop between them left the row
// `activation_pending` with pending_packet already cleared — and
// tick() has nothing to retry from there, so the installation was
// registered with the collector but permanently stuck locally.
// Still guarded on activation_pending, so a withdrawal that arrived
// first is not overwritten.
const ack = this.db('product_usage_state').where({ id: 1 });
if (packet.action === 'register') {
update.status = 'active';
// Precisely activation_pending, not merely "not withdrawing" —
// this write is the one that turns participation on.
ack.where({ status: 'activation_pending' });
} else {
ack.whereNot({ status: 'deletion_pending' });
}
await ack.update(update);
await this.db('product_usage_state')
.where({ id: 1, status: 'deletion_pending' })
.update({ sequence: packet.sequence, pending_packet: null });
if (packet.action === 'register')
await this.db('product_usage_state')
.where({ id: 1, status: 'activation_pending' })
.update({ status: 'active' });
}
return receipt;
} catch (error) {