Merge remote-tracking branch 'origin/main' into wilm
This commit is contained in:
@@ -12,6 +12,11 @@ time (NoReferencedTableError). Same shape autogenerate produces. Tables inherit
|
||||
the connection's default charset, matching how the core chain creates its own.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -23,7 +28,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'backuprevisions',
|
||||
sa.Column('backuprevisionid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
@@ -42,15 +47,15 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('backuprevisionid'),
|
||||
)
|
||||
op.create_index('ixbackuprevisionsassetid', 'backuprevisions', ['assetid'])
|
||||
op.create_index('ixbackuprevisionsbackupkind', 'backuprevisions',
|
||||
['backupkind'])
|
||||
op.create_index('ixbackuprevisionscontenthash', 'backuprevisions',
|
||||
['contenthash'])
|
||||
create_index_if_missing('ixbackuprevisionsassetid', 'backuprevisions', ['assetid'])
|
||||
create_index_if_missing('ixbackuprevisionsbackupkind', 'backuprevisions',
|
||||
['backupkind'])
|
||||
create_index_if_missing('ixbackuprevisionscontenthash', 'backuprevisions',
|
||||
['contenthash'])
|
||||
# The dedup read path is "latest revision for this asset+kind", so the
|
||||
# composite index is the one that actually gets used on every collector post.
|
||||
op.create_index('ixbackuprevisionsassetkind', 'backuprevisions',
|
||||
['assetid', 'backupkind'])
|
||||
create_index_if_missing('ixbackuprevisionsassetkind', 'backuprevisions',
|
||||
['assetid', 'backupkind'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -16,16 +16,14 @@ from .api import computers_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Marker stamped on PC->printer links this collector creates. The stale-link
|
||||
# archive only touches rows carrying this label, so manually-created printer
|
||||
# relationships are never removed by a collector push. Stored in the
|
||||
# assetrelationships.label column (no origin column exists; see BUILD notes).
|
||||
PRINTER_LINK_ORIGIN = 'collector:printers'
|
||||
|
||||
# Marker stamped on PC->measuringtool "controls" links this collector creates
|
||||
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link
|
||||
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived
|
||||
# by a collector push, so hand-made tool links survive.
|
||||
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Only rows carrying
|
||||
# this label are archived by a collector push, so hand-made tool links survive.
|
||||
# Stored in assetrelationships.label (no origin column exists; see BUILD notes).
|
||||
#
|
||||
# There is deliberately no printer equivalent any more. PRINTER_LINK_ORIGIN went
|
||||
# with _sync_printer_links: printer ASSIGNMENT is never collected, so there are
|
||||
# no collector-owned printer rows left to label or archive.
|
||||
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
|
||||
# Marker stamped on the PC->machine "controls" link built from the reported
|
||||
@@ -363,26 +361,10 @@ class ComputersPlugin(BasePlugin):
|
||||
'type': 'array',
|
||||
'items': {'name': 'string', 'version': 'string'},
|
||||
},
|
||||
# Printer identifiers reported by the GE-Enforce side, which
|
||||
# runs Get-CimInstance Win32_Printer and marks the default with
|
||||
# the Default flag. An identifier is the printer's windows name,
|
||||
# share name, hostname, or port/IP; the collector resolves it
|
||||
# flexibly to a printer asset. Both optional. Presence of either
|
||||
# key drives the PC->printer relationship sync (and stale-link
|
||||
# archive); absence leaves existing printer links untouched.
|
||||
'defaultprinter': {
|
||||
'type': 'string',
|
||||
'description': ('Default printer identifier (Win32_Printer '
|
||||
'with Default=true): windows name, share '
|
||||
'name, hostname, or port IP.'),
|
||||
},
|
||||
'printers': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': ('All installed network printer identifiers '
|
||||
'(Win32_Printer): windows name / share / '
|
||||
'hostname / IP. Unresolved -> warning.'),
|
||||
},
|
||||
# RETIRED: 'defaultprinter' and 'printers' used to live here and
|
||||
# wrote PC->printer assignment rows from a report. That is the
|
||||
# one thing this path must never do - see _RETIRED_PRINTER_KEYS.
|
||||
# A client still sending them is warned, not rejected.
|
||||
'accessprotocols': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
@@ -647,8 +629,9 @@ class ComputersPlugin(BasePlugin):
|
||||
else:
|
||||
machinelinks = []
|
||||
|
||||
# Printer relationship sync (only when the payload carried printer data).
|
||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||
# Printer assignment is NOT collected. See _warn_retired_printer_keys.
|
||||
self._warn_retired_printer_keys(payload, warnings)
|
||||
printerlinks = []
|
||||
|
||||
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
|
||||
# get an attached MeasuringTool asset auto-created and linked.
|
||||
@@ -742,134 +725,38 @@ class ComputersPlugin(BasePlugin):
|
||||
AccessProtocol.protocolid.in_(wanted)).all()] if wanted else []
|
||||
return sorted(names)
|
||||
|
||||
def _sync_printer_links(self, pcasset, payload, warnings):
|
||||
"""Idempotently sync PC->printer relationships from collector printer data.
|
||||
def _warn_retired_printer_keys(self, payload, warnings):
|
||||
"""Refuse to turn a report into an assignment, out loud.
|
||||
|
||||
Default printer -> 'defaultprinter' (directional). Other reported
|
||||
printers -> 'connectedto' (symmetric). Resolves each identifier to a
|
||||
printer asset by windows name / share / hostname / asset number / name
|
||||
or a communications IP. Unresolved identifiers add a warning and never
|
||||
fail the push.
|
||||
RETIRED 2026-08-24. This used to be _sync_printer_links, which wrote
|
||||
'defaultprinter' and 'connectedto' rows straight from a collector
|
||||
payload. That contradicted the rule the whole printer feature rests on:
|
||||
ASSIGNED and OBSERVED never mix. A bay that installed the wrong printer
|
||||
would have made itself right simply by reporting it, and enforcement
|
||||
would stop meaning anything.
|
||||
|
||||
Stale-link archive: on each push, collector-tagged links (label ==
|
||||
PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported
|
||||
desired set are set inactive. Only tagged rows are touched, so manual
|
||||
links survive. Runs only when the payload carried a printer key
|
||||
('defaultprinter' or 'printers'); a PC that reports without printer data
|
||||
keeps its existing links. Returns the desired-link list (created + kept).
|
||||
Worse than dormant, it was ADVERTISED: the collector schema listed both
|
||||
keys and COLLECTOR-INTEGRATION.md's example client set defaultprinter,
|
||||
while the same document promised on another page that nothing on this
|
||||
path writes an assignment row. The document contradicted itself and the
|
||||
code sided with the wrong half.
|
||||
|
||||
Observed queues have their own path (POST /api/collector/printers) and
|
||||
become assignments only when a person asks, through
|
||||
seed-from-observed after reviewing the comparison.
|
||||
|
||||
WARNS RATHER THAN REJECTS. Unknown keys are not validated here, so a
|
||||
client written against the old docs keeps pushing successfully; only its
|
||||
printer keys stop doing anything. Silently ignoring them would leave an
|
||||
integrator believing assignments were being written.
|
||||
"""
|
||||
from shopdb.api import (
|
||||
AssetRelationship, RelationshipType, Asset, Communication)
|
||||
|
||||
has_default = 'defaultprinter' in payload
|
||||
has_list = 'printers' in payload
|
||||
if not has_default and not has_list:
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.printers.models import Printer
|
||||
except ImportError:
|
||||
warnings.append('printers plugin unavailable; printer links skipped')
|
||||
return []
|
||||
|
||||
dp_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='defaultprinter').first()
|
||||
ct_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='connectedto').first()
|
||||
if not dp_type or not ct_type:
|
||||
warnings.append('printer relationship types missing; '
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
def resolve(identifier):
|
||||
# first match wins: printer text identity, then a printer IP.
|
||||
ident = (identifier or '').strip()
|
||||
if not ident:
|
||||
return None
|
||||
printer = (
|
||||
Printer.query.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.filter(Asset.isactive == True)
|
||||
.filter(db.or_(
|
||||
Printer.windowsname.ilike(ident),
|
||||
Printer.hostname.ilike(ident),
|
||||
Printer.sharename.ilike(ident),
|
||||
Asset.assetnumber.ilike(ident),
|
||||
Asset.name.ilike(ident),
|
||||
)).first())
|
||||
if printer:
|
||||
return printer.asset
|
||||
comm = (
|
||||
db.session.query(Communication)
|
||||
.join(Printer, Printer.assetid == Communication.assetid)
|
||||
.filter(Communication.ipaddress == ident)
|
||||
.first())
|
||||
if comm:
|
||||
return db.session.get(Asset, comm.assetid)
|
||||
return None
|
||||
|
||||
pcid = pcasset.assetid
|
||||
desired = set() # (targetassetid, relationshiptypeid) to keep
|
||||
printerlinks = []
|
||||
|
||||
default_id = None
|
||||
default_ident = (payload.get('defaultprinter') or '').strip()
|
||||
if default_ident:
|
||||
target = resolve(default_ident)
|
||||
if target:
|
||||
default_id = target.assetid
|
||||
desired.add((default_id, dp_type.relationshiptypeid))
|
||||
self._sync_one(pcid, default_id, dp_type)
|
||||
printerlinks.append({'assetid': default_id,
|
||||
'relationshiptype': 'defaultprinter'})
|
||||
else:
|
||||
warnings.append(f'unresolved default printer: {default_ident}')
|
||||
|
||||
for ident in payload.get('printers') or []:
|
||||
target = resolve(ident)
|
||||
if not target:
|
||||
warnings.append(f'unresolved printer: {ident}')
|
||||
continue
|
||||
if target.assetid == default_id:
|
||||
continue # already the default link
|
||||
desired.add((target.assetid, ct_type.relationshiptypeid))
|
||||
self._sync_one(pcid, target.assetid, ct_type)
|
||||
printerlinks.append({'assetid': target.assetid,
|
||||
'relationshiptype': 'connectedto'})
|
||||
|
||||
# Archive collector-tagged links no longer reported (manual links, with
|
||||
# a NULL/other label, are never matched here).
|
||||
collector_rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcid,
|
||||
AssetRelationship.relationshiptypeid.in_(
|
||||
[dp_type.relationshiptypeid, ct_type.relationshiptypeid]),
|
||||
AssetRelationship.isactive == True,
|
||||
AssetRelationship.label == PRINTER_LINK_ORIGIN,
|
||||
).all()
|
||||
for rel in collector_rows:
|
||||
if (rel.targetassetid, rel.relationshiptypeid) not in desired:
|
||||
rel.isactive = False
|
||||
|
||||
return printerlinks
|
||||
|
||||
def _sync_one(self, pcid, printerid, reltype):
|
||||
"""Reactivate or create one collector PC->printer link (idempotent)."""
|
||||
from shopdb.api import AssetRelationship
|
||||
existing = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid).first()
|
||||
if existing:
|
||||
# do not re-stamp label: a pre-existing manual row stays manual.
|
||||
if not existing.isactive:
|
||||
existing.isactive = True
|
||||
return existing
|
||||
rel = AssetRelationship(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid,
|
||||
label=PRINTER_LINK_ORIGIN)
|
||||
db.session.add(rel)
|
||||
return rel
|
||||
|
||||
# -- measuring-tool sync -----------------------------------------------
|
||||
sent = [key for key in ('defaultprinter', 'printers') if key in payload]
|
||||
if sent:
|
||||
warnings.append(
|
||||
'%s ignored: a collector report never sets printer assignment. '
|
||||
'Report queues to POST /api/collector/printers, then adopt them '
|
||||
'with POST /api/printers/assignments/seed-from-observed.'
|
||||
% ' and '.join(sent))
|
||||
|
||||
def _sync_machine_link(self, comp, machinenumber, warnings,
|
||||
origin_label=MACHINE_LINK_ORIGIN, preview=False):
|
||||
|
||||
@@ -8,6 +8,11 @@ tables are created parent-first. computertypeid / measuringtooltypeid /
|
||||
publishedby are SOFT references (plain columns, no FK) to optional plugins/core.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -19,7 +24,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestscopes',
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopename', sa.String(length=64), nullable=False),
|
||||
@@ -37,7 +42,7 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('scopeid'),
|
||||
sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
|
||||
)
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestentries',
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
@@ -80,10 +85,10 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('entryid'),
|
||||
sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
|
||||
)
|
||||
op.create_index('idx_entry_scope', 'manifestentries', ['scopeid'])
|
||||
op.create_index('idx_entry_scope_order', 'manifestentries',
|
||||
['scopeid', 'sortorder'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_entry_scope', 'manifestentries', ['scopeid'])
|
||||
create_index_if_missing('idx_entry_scope_order', 'manifestentries',
|
||||
['scopeid', 'sortorder'])
|
||||
create_table_if_missing(
|
||||
'manifestentrypctypes',
|
||||
sa.Column('entrypctypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -93,8 +98,8 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrypctypeid'),
|
||||
)
|
||||
op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestentryhostnames',
|
||||
sa.Column('entryhostnameid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -104,8 +109,8 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entryhostnameid'),
|
||||
)
|
||||
op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestentrymachinenumbers',
|
||||
sa.Column('entrymachinenumberid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -115,9 +120,9 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrymachinenumberid'),
|
||||
)
|
||||
op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers',
|
||||
['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_machinenumber_entry', 'manifestentrymachinenumbers',
|
||||
['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestinusechecks',
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -127,7 +132,7 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('inusecheckid'),
|
||||
sa.UniqueConstraint('entryid'),
|
||||
)
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestinusecheckprocesses',
|
||||
sa.Column('inusecheckprocessid', sa.Integer(), nullable=False),
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
@@ -140,9 +145,9 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('inusecheckprocessid'),
|
||||
)
|
||||
op.create_index('idx_process_check', 'manifestinusecheckprocesses',
|
||||
['inusecheckid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_process_check', 'manifestinusecheckprocesses',
|
||||
['inusecheckid'])
|
||||
create_table_if_missing(
|
||||
'manifestpublishedversions',
|
||||
sa.Column('publishedversionid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
@@ -158,9 +163,9 @@ def upgrade():
|
||||
sa.UniqueConstraint('scopeid', 'versionnumber',
|
||||
name='uq_published_scope_version'),
|
||||
)
|
||||
op.create_index('idx_published_scope', 'manifestpublishedversions',
|
||||
['scopeid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_published_scope', 'manifestpublishedversions',
|
||||
['scopeid'])
|
||||
create_table_if_missing(
|
||||
'manifestpayloads',
|
||||
sa.Column('payloadid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -173,8 +178,8 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('payloadid'),
|
||||
)
|
||||
op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_payload_entry', 'manifestpayloads', ['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestenforcementreports',
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=100), nullable=False),
|
||||
@@ -192,13 +197,13 @@ def upgrade():
|
||||
sa.Column('iscurrent', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('reportid'),
|
||||
)
|
||||
op.create_index('idx_report_host', 'manifestenforcementreports',
|
||||
['hostname'])
|
||||
op.create_index('idx_report_current', 'manifestenforcementreports',
|
||||
['iscurrent'])
|
||||
op.create_index('idx_report_host_scope', 'manifestenforcementreports',
|
||||
['hostname', 'scopename', 'phase'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_report_host', 'manifestenforcementreports',
|
||||
['hostname'])
|
||||
create_index_if_missing('idx_report_current', 'manifestenforcementreports',
|
||||
['iscurrent'])
|
||||
create_index_if_missing('idx_report_host_scope', 'manifestenforcementreports',
|
||||
['hostname', 'scopename', 'phase'])
|
||||
create_table_if_missing(
|
||||
'manifestenforcementresults',
|
||||
sa.Column('resultid', sa.Integer(), nullable=False),
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
@@ -212,9 +217,9 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('resultid'),
|
||||
)
|
||||
op.create_index('idx_result_report', 'manifestenforcementresults',
|
||||
['reportid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_result_report', 'manifestenforcementresults',
|
||||
['reportid'])
|
||||
create_table_if_missing(
|
||||
'pctypealiases',
|
||||
sa.Column('aliasid', sa.Integer(), nullable=False),
|
||||
sa.Column('aliasgroup', sa.Integer(), nullable=False),
|
||||
@@ -223,7 +228,7 @@ def upgrade():
|
||||
sa.UniqueConstraint('aliasgroup', 'aliasname',
|
||||
name='uq_alias_group_name'),
|
||||
)
|
||||
op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup'])
|
||||
create_index_if_missing('idx_alias_group', 'pctypealiases', ['aliasgroup'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -10,6 +10,11 @@ Create Date: 2026-07-21
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -24,7 +29,7 @@ def upgrade():
|
||||
insp = sa.inspect(bind)
|
||||
if 'manifestblobs' in insp.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestblobs',
|
||||
sa.Column('sha256', sa.String(length=64), nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||
|
||||
@@ -379,6 +379,7 @@ import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { planControllingPcChange } from './controllingPc'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
@@ -440,6 +441,9 @@ const relationshipTypes = ref([])
|
||||
const controllingPcId = ref(null)
|
||||
const relationshipTypeId = ref(null)
|
||||
const existingRelationshipId = ref(null)
|
||||
// Which PC the link named when the form loaded, so an unchanged one can be left
|
||||
// alone. Editing a machine is mostly editing something else about it.
|
||||
const loadedControllingPcId = ref(null)
|
||||
const currentMachine = ref(null)
|
||||
|
||||
// Filter models by selected machine vendor
|
||||
@@ -575,7 +579,16 @@ onMounted(async () => {
|
||||
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
|
||||
controllingPcId.value = rel.sourceasset.assetid
|
||||
relationshipTypeId.value = rel.relationshiptypeid
|
||||
existingRelationshipId.value = rel.assetrelationshipid
|
||||
// relationshipid. `assetrelationshipid` is not a key the API has
|
||||
// ever emitted - to_dict serialises the column names and the
|
||||
// column is relationshipid - so this read undefined every time,
|
||||
// and saveRelationship then took the "there is nothing to
|
||||
// replace" path on a machine that had a controlling PC. Saving
|
||||
// ANY change, a map position included, re-POSTed the link that
|
||||
// was already there and the server correctly answered 409 "this
|
||||
// relationship already exists".
|
||||
existingRelationshipId.value = rel.relationshipid
|
||||
loadedControllingPcId.value = rel.sourceasset.assetid
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -693,31 +706,40 @@ async function saveMachine() {
|
||||
}
|
||||
|
||||
async function saveRelationship(assetId) {
|
||||
// If no PC selected and no existing relationship, nothing to do
|
||||
if (!controllingPcId.value && !existingRelationshipId.value) {
|
||||
// The decision lives in controllingPc.js, where it is covered by tests.
|
||||
// See that file for why "is there a link" and "do I know its id" are two
|
||||
// different questions and what happened when they were treated as one.
|
||||
const plan = planControllingPcChange({
|
||||
selected: controllingPcId.value,
|
||||
loaded: loadedControllingPcId.value,
|
||||
existingRelationshipId: existingRelationshipId.value,
|
||||
})
|
||||
|
||||
if (plan.action === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
// If clearing the relationship
|
||||
if (!controllingPcId.value && existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
if (plan.deleteId !== null) {
|
||||
await assetsApi.deleteRelationship(plan.deleteId)
|
||||
existingRelationshipId.value = null
|
||||
loadedControllingPcId.value = null
|
||||
}
|
||||
|
||||
if (plan.action === 'unlink') {
|
||||
return
|
||||
}
|
||||
|
||||
// If PC is selected
|
||||
if (controllingPcId.value) {
|
||||
// If there's an existing relationship, delete it first
|
||||
if (existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
}
|
||||
// PC controls Machine, so the PC is the source and the machine the target.
|
||||
const created = await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
relationshiptypeid: relationshipTypeId.value
|
||||
})
|
||||
|
||||
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
relationshiptypeid: relationshipTypeId.value
|
||||
})
|
||||
}
|
||||
// Keep the form honest about what is now stored, so a second save without a
|
||||
// reload does not repeat the create it just did.
|
||||
existingRelationshipId.value = created?.data?.data?.relationshipid ?? null
|
||||
loadedControllingPcId.value = controllingPcId.value
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
68
plugins/machines/frontend/views/controllingPc.js
Normal file
68
plugins/machines/frontend/views/controllingPc.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// What saving the machine form should do about its controlling-PC link.
|
||||
//
|
||||
// Pulled out of MachineForm.vue because the component decided this inline and
|
||||
// got it wrong in a way nothing could catch: it treated "I know the id of the
|
||||
// existing link" as "there IS an existing link". The id was read from
|
||||
// `rel.assetrelationshipid`, a key the API has never emitted - the column is
|
||||
// `relationshipid` and to_dict serialises column names - so it was undefined on
|
||||
// every load, and the two questions gave opposite answers on a machine that
|
||||
// plainly had a controlling PC.
|
||||
//
|
||||
// Both failures followed from that single conflation:
|
||||
// - Saving ANY change, a map position included, re-POSTed the link that was
|
||||
// already there. The server answered 409 "this relationship already
|
||||
// exists", correctly, and the form surfaced it as a failed save.
|
||||
// - Clearing the controlling PC did nothing at all, because "no selection and
|
||||
// no known id" reads as "nothing to do".
|
||||
//
|
||||
// So THE LOADED PC IS THE AUTHORITY on whether a link exists, and the
|
||||
// relationship id is only ever used to delete a row. If the id goes missing
|
||||
// again the worst case is a link that cannot be removed, never a duplicate
|
||||
// create.
|
||||
|
||||
/**
|
||||
* Decide what to do with the controlling-PC link on save.
|
||||
*
|
||||
* @param {object} state
|
||||
* @param {number|string|null} state.selected - PC id chosen in the form now.
|
||||
* @param {number|string|null} state.loaded - PC id the link named on load.
|
||||
* @param {number|null} state.existingRelationshipId - row id to delete, if known.
|
||||
* @returns {{action: 'none'|'unlink'|'link'|'relink', deleteId: number|null}}
|
||||
*/
|
||||
export function planControllingPcChange({ selected, loaded, existingRelationshipId } = {}) {
|
||||
const now = normalize(selected)
|
||||
const before = normalize(loaded)
|
||||
const deleteId = existingRelationshipId ?? null
|
||||
|
||||
// Unchanged is not a change. Somebody moving a marker on the map is not
|
||||
// asking to be re-linked to the same PC, and delete-then-create on an
|
||||
// untouched link opens a window where the machine has no controlling PC -
|
||||
// which resolve_asset_position walks across, so a failure between the two
|
||||
// calls would lose the machine's inherited position along with the link.
|
||||
if (now === before) {
|
||||
return { action: 'none', deleteId: null }
|
||||
}
|
||||
|
||||
if (now === null) {
|
||||
// Cleared. Nothing to delete means nothing to do, rather than an error:
|
||||
// the link is already absent, which is the state being asked for.
|
||||
return deleteId === null
|
||||
? { action: 'none', deleteId: null }
|
||||
: { action: 'unlink', deleteId }
|
||||
}
|
||||
|
||||
if (before === null) {
|
||||
return { action: 'link', deleteId: null }
|
||||
}
|
||||
|
||||
// Swapped to a different PC. The old row goes first: 'controls' is singular
|
||||
// per source, not per target, so leaving it would leave the machine with two.
|
||||
return { action: 'relink', deleteId }
|
||||
}
|
||||
|
||||
// Ids arrive as numbers from the API and strings from a <select>, and '' means
|
||||
// "nothing chosen". Comparing those raw is how an unchanged PC looks changed.
|
||||
function normalize(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return String(value)
|
||||
}
|
||||
81
plugins/machines/frontend/views/controllingPc.spec.js
Normal file
81
plugins/machines/frontend/views/controllingPc.spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { planControllingPcChange } from './controllingPc'
|
||||
|
||||
// Reported: moving machine 3016 on the map, then saving, answered "this
|
||||
// relationship already exists" and refused the save. The machine had a
|
||||
// controlling PC and the form re-created that link on every save, because it
|
||||
// asked "do I know the existing link's id" instead of "is there an existing
|
||||
// link" - and the id was read from a key the API does not emit, so it was
|
||||
// always undefined.
|
||||
//
|
||||
// The first two specs are that bug. The rest pin the behaviour it was hiding.
|
||||
|
||||
describe('planControllingPcChange', () => {
|
||||
it('does nothing when the controlling PC is unchanged', () => {
|
||||
// The reported case: only the map position moved.
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('still does nothing when the existing row id was never learned', () => {
|
||||
// The exact shape of the bug: the link is there, its id is not known. The
|
||||
// answer must be "leave it alone", never "create it again" - a create here
|
||||
// is the 409.
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: 42, existingRelationshipId: undefined,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('removes the link when the PC is cleared', () => {
|
||||
// This never fired before either: no selection and no known id read as
|
||||
// "nothing to do", so clearing a controlling PC silently kept it.
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'unlink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('creates a link when there was none', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: null, existingRelationshipId: null,
|
||||
})).toEqual({ action: 'link', deleteId: null })
|
||||
})
|
||||
|
||||
it('replaces the old row when the PC is swapped', () => {
|
||||
// controls is singular per SOURCE, not per target, so the old row has to go
|
||||
// or the machine ends up with two controlling PCs.
|
||||
expect(planControllingPcChange({
|
||||
selected: 99, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'relink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('treats a select string and an API number as the same PC', () => {
|
||||
// A <select> hands back '42' while the relationship carried 42. Comparing
|
||||
// those raw makes an untouched link look like a swap, which is the same
|
||||
// duplicate POST by another route.
|
||||
expect(planControllingPcChange({
|
||||
selected: '42', loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('treats an empty selection as cleared, not as a PC named ""', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: '', loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'unlink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('does nothing when there was no PC and none was chosen', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: null, existingRelationshipId: null,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('does not attempt a delete it cannot perform', () => {
|
||||
// Cleared, but the row id was never learned. Asking the API to delete
|
||||
// `undefined` produced a request against /relationships/undefined.
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: 42, existingRelationshipId: undefined,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,11 @@ connection's default charset, so on a utf8mb4 database they are utf8mb4, matchin
|
||||
how the core chain creates its tables.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -28,7 +33,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'measuringtooltypes',
|
||||
sa.Column('measuringtooltypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
@@ -40,7 +45,7 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('measuringtooltypeid'),
|
||||
sa.UniqueConstraint('name'),
|
||||
)
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'measuringtools',
|
||||
sa.Column('measuringtoolid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
@@ -59,8 +64,8 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('measuringtoolid'),
|
||||
sa.UniqueConstraint('assetid'),
|
||||
)
|
||||
op.create_index('idx_measuringtool_type', 'measuringtools',
|
||||
['measuringtooltypeid'])
|
||||
create_index_if_missing('idx_measuringtool_type', 'measuringtools',
|
||||
['measuringtooltypeid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -11,6 +11,11 @@ ops are written out explicitly anyway to match the measuringtools exemplar
|
||||
and keep the baseline reviewable.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -22,7 +27,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printeditems',
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
sa.Column('itemcode', sa.String(length=20), nullable=True),
|
||||
@@ -39,9 +44,9 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('printeditemid'),
|
||||
sa.UniqueConstraint('itemcode'),
|
||||
)
|
||||
op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
||||
create_index_if_missing('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
||||
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printeditemtransactions',
|
||||
sa.Column('transactionid', sa.Integer(), nullable=False),
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
@@ -58,12 +63,12 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('transactionid'),
|
||||
)
|
||||
op.create_index('ix_printeditemtransactions_printeditemid',
|
||||
'printeditemtransactions', ['printeditemid'])
|
||||
op.create_index('ix_printeditemtransactions_employeesso',
|
||||
'printeditemtransactions', ['employeesso'])
|
||||
op.create_index('ix_printeditemtransactions_transactiondate',
|
||||
'printeditemtransactions', ['transactiondate'])
|
||||
create_index_if_missing('ix_printeditemtransactions_printeditemid',
|
||||
'printeditemtransactions', ['printeditemid'])
|
||||
create_index_if_missing('ix_printeditemtransactions_employeesso',
|
||||
'printeditemtransactions', ['employeesso'])
|
||||
create_index_if_missing('ix_printeditemtransactions_transactiondate',
|
||||
'printeditemtransactions', ['transactiondate'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -5,6 +5,11 @@ the ADR-008 payoff: the plugin evolves its own schema without touching
|
||||
the core chain. Applied by `flask plugin upgrade-all`.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -16,7 +21,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printeditemfiles',
|
||||
sa.Column('fileid', sa.Integer(), nullable=False),
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
@@ -35,8 +40,8 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('fileid'),
|
||||
sa.UniqueConstraint('storedfilename'),
|
||||
)
|
||||
op.create_index('ix_printeditemfiles_printeditemid',
|
||||
'printeditemfiles', ['printeditemid'])
|
||||
create_index_if_missing('ix_printeditemfiles_printeditemid',
|
||||
'printeditemfiles', ['printeditemid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -4,6 +4,11 @@ Stores the last tier alerted per (printer, supply) so the poller fires once
|
||||
on a downward crossing and re-arms after a refill. Idempotent create.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -19,7 +24,7 @@ def upgrade():
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printersupplyalerts' in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printersupplyalerts',
|
||||
sa.Column('printersupplyalertid', sa.Integer(), primary_key=True),
|
||||
sa.Column('printerid', sa.Integer(), nullable=False),
|
||||
@@ -35,8 +40,8 @@ def upgrade():
|
||||
sa.UniqueConstraint('printerid', 'supplykey',
|
||||
name='uq_printersupplyalert_printer_key'),
|
||||
)
|
||||
op.create_index('ix_printersupplyalerts_printerid',
|
||||
'printersupplyalerts', ['printerid'])
|
||||
create_index_if_missing('ix_printersupplyalerts_printerid',
|
||||
'printersupplyalerts', ['printerid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -21,6 +21,11 @@ Revision ID: printers0005observedqueues
|
||||
Revises: printers0004drivervendor
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -36,7 +41,7 @@ def upgrade():
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerobservedqueues' in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printerobservedqueues',
|
||||
sa.Column('printerobservedqueueid', sa.Integer(), nullable=False),
|
||||
# Nullable and no index of its own beyond the explicit one below: an
|
||||
@@ -67,10 +72,10 @@ def upgrade():
|
||||
)
|
||||
# Port address is the primary match key from an observed queue back to a
|
||||
# printer asset, so every comparison read hits it.
|
||||
op.create_index('idx_printerobservedqueues_portaddress',
|
||||
'printerobservedqueues', ['portaddress'])
|
||||
op.create_index('idx_printerobservedqueues_assetid',
|
||||
'printerobservedqueues', ['assetid'])
|
||||
create_index_if_missing('idx_printerobservedqueues_portaddress',
|
||||
'printerobservedqueues', ['portaddress'])
|
||||
create_index_if_missing('idx_printerobservedqueues_assetid',
|
||||
'printerobservedqueues', ['assetid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -6,9 +6,15 @@ on first read needs somewhere to keep the ciphertext until then.
|
||||
|
||||
TWO DIFFERENT DOORS, on purpose.
|
||||
|
||||
CREATE needs a login and a permission. Anonymous creation would be an
|
||||
untraceable way to move data out of a system that otherwise records who did
|
||||
what, and the creator is the only person these rows can ever name.
|
||||
CREATE needs a LOGIN and nothing more. Sharing a password is something anybody
|
||||
at the site may legitimately need to do, and a permission somebody has to be
|
||||
granted first turns a two-minute job into a ticket - which is how people end up
|
||||
pasting credentials into chat instead.
|
||||
|
||||
The login stays, though, and is not a formality: anonymous creation would make
|
||||
this an unattributable write endpoint that stores arbitrary ciphertext, and the
|
||||
creator is the only person these rows can ever name. Everybody browsing this app
|
||||
is already signed in, so requiring it costs nothing.
|
||||
|
||||
READ needs neither, because the recipient usually has no account here and the
|
||||
link is normally handed over in a call or a chat. The unguessable token IS the
|
||||
@@ -27,12 +33,12 @@ import secrets as pysecrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from flask import current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
db, cache, Setting,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import SecureSecret
|
||||
@@ -50,18 +56,113 @@ MAX_CIPHERTEXT = 4096
|
||||
DEFAULT_EXPIRY_HOURS = 24
|
||||
MAX_EXPIRY_HOURS = 168
|
||||
|
||||
MAX_VIEWS = 5
|
||||
# Generous, because "how many people need this password" is the site's business
|
||||
# and not a number worth arguing with. The floor matters more than the ceiling:
|
||||
# a secret readable an unlimited number of times is a password on a web page.
|
||||
MAX_VIEWS = 100
|
||||
|
||||
# Guest creation, when a site enables it, is an unauthenticated write. Bounded
|
||||
# per address so it cannot be used to fill a table or park data: a person
|
||||
# sharing passwords does it a handful of times an hour, not hundreds.
|
||||
GUEST_CREATE_MAX = 10
|
||||
GUEST_CREATE_WINDOW_SECONDS = 600
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _sweep_expired():
|
||||
"""Delete secrets whose window has passed, whoever asked.
|
||||
|
||||
Redeeming removes a row, and so does touching an expired one - but a secret
|
||||
nobody ever opens was removed by NOTHING. The recipient goes on holiday, the
|
||||
link is never clicked, and the ciphertext sits there for good: not a
|
||||
disclosure risk, since it cannot be read, but it makes "nothing lingers"
|
||||
untrue and grows the table in exactly the case where the feature failed.
|
||||
|
||||
Swept opportunistically rather than on a schedule: creating a secret is
|
||||
authenticated and infrequent, and an air-gapped site is not going to have a
|
||||
cron job anybody remembers to add.
|
||||
"""
|
||||
stale = SecureSecret.query.filter(SecureSecret.expiresat < _utcnow()).all()
|
||||
for secret in stale:
|
||||
db.session.delete(secret)
|
||||
return len(stale)
|
||||
|
||||
|
||||
def _guests_allowed():
|
||||
"""May somebody with no account create a secret? Default NO.
|
||||
|
||||
Read straight from the row rather than the settings cache: this decides
|
||||
whether an unauthenticated caller may write, and being wrong on the
|
||||
permissive side for five minutes is the wrong way round to be wrong.
|
||||
"""
|
||||
try:
|
||||
row = Setting.query.filter_by(key='securesend_allow_guests').first()
|
||||
except Exception:
|
||||
# FAIL CLOSED. If the setting cannot be read - the row is missing, the
|
||||
# table is not there yet on a half-migrated site, the database is
|
||||
# unhappy - the answer is no. An error must never be the thing that
|
||||
# opens an unauthenticated write endpoint.
|
||||
current_app.logger.exception('could not read securesend_allow_guests')
|
||||
return False
|
||||
return bool(row and str(row.value).strip().lower() in ('true', '1', 'yes'))
|
||||
|
||||
|
||||
def _guest_ratelimited():
|
||||
"""Bounded guest creation, per address, in a fixed window.
|
||||
|
||||
Same shape as the login limiter. Not a security boundary - a determined
|
||||
person has more addresses - but it stops an open endpoint being an easy
|
||||
place to park data or fill a disk.
|
||||
"""
|
||||
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or 'unknown')
|
||||
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
|
||||
key = f'securesendguest:{address}:{bucket}'
|
||||
used = cache.get(key) or 0
|
||||
if used >= GUEST_CREATE_MAX:
|
||||
return True
|
||||
cache.set(key, used + 1, timeout=GUEST_CREATE_WINDOW_SECONDS)
|
||||
return False
|
||||
|
||||
|
||||
@tools_bp.route('/secrets', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('tools.secrets')
|
||||
@jwt_required(optional=True)
|
||||
def create_secret():
|
||||
"""Store an already-encrypted secret and return its token."""
|
||||
"""Store an already-encrypted secret and return its token.
|
||||
|
||||
OPTIONAL jwt so the SETTING decides rather than the decorator: a guest has
|
||||
to reach the handler to be told whether this site lets them in.
|
||||
"""
|
||||
# WHO MAY CREATE. Signed in: the tools.secrets permission, with the usual
|
||||
# admin bypass. Not signed in: only when the site has deliberately opened
|
||||
# it, and then within a rate limit.
|
||||
# get_jwt_identity(), NOT `current_user is None`: current_user is a
|
||||
# LocalProxy that is never None itself, so that test silently took the
|
||||
# signed-in branch for a guest and then blew up dereferencing it.
|
||||
identity = get_jwt_identity()
|
||||
if identity is None:
|
||||
if not _guests_allowed():
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Sign in to create a secret link.',
|
||||
http_code=401)
|
||||
if _guest_ratelimited():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Too many links from this address. Try again shortly.',
|
||||
http_code=429)
|
||||
elif not current_user.haspermission('tools.secrets'):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to create secret links.',
|
||||
http_code=403)
|
||||
# Tidy up on the way past. Cheap, and it keeps the table bounded without a
|
||||
# scheduler existing anywhere.
|
||||
_sweep_expired()
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
ciphertext = (data.get('ciphertext') or '').strip()
|
||||
iv = (data.get('iv') or '').strip()
|
||||
@@ -173,4 +274,9 @@ def peek_secret(token):
|
||||
"""
|
||||
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
|
||||
live = bool(secret and not secret.is_expired() and not secret.is_spent())
|
||||
if secret is not None and not live:
|
||||
# A late click tidies its own row, so a dead secret does not need
|
||||
# somebody to create a new one before it is cleaned up.
|
||||
db.session.delete(secret)
|
||||
db.session.commit()
|
||||
return success_response({'available': live})
|
||||
|
||||
23
plugins/tools/frontend/api.js
Normal file
23
plugins/tools/frontend/api.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Tech Tools API client.
|
||||
//
|
||||
// Only secure send needs one - every other tool runs entirely in the browser
|
||||
// and talks to nothing.
|
||||
import api from '@/api'
|
||||
|
||||
export const toolsApi = {
|
||||
createSecret(payload) {
|
||||
return api.post('/tools/secrets', payload)
|
||||
},
|
||||
|
||||
// Is the link still live? Spends nothing, so it is safe for the read page to
|
||||
// ask before anyone clicks.
|
||||
peekSecret(token) {
|
||||
return api.get(`/tools/secrets/${token}`)
|
||||
},
|
||||
|
||||
// POST, not GET, and that is deliberate: chat and mail clients prefetch GET
|
||||
// urls to build previews, and a preview must not consume a one-view secret.
|
||||
redeemSecret(token) {
|
||||
return api.post(`/tools/secrets/${token}`)
|
||||
},
|
||||
}
|
||||
@@ -15,12 +15,48 @@ export default [
|
||||
path: 'tools',
|
||||
name: 'tools',
|
||||
component: () => import('./views/ToolsIndex.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'tools' }
|
||||
// NO requiresAuth. Every tool here is usable without an account: the label
|
||||
// generator runs entirely in the browser, and secure send decides for
|
||||
// itself (a site can let guests create, and receiving never needs a login).
|
||||
// Gating the index turned a public tool into one nobody could reach.
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
{
|
||||
path: 'tools/codes',
|
||||
name: 'tools-codes',
|
||||
component: () => import('./views/CodeGenerator.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'tools' }
|
||||
// Browser-only: no API, no data, nothing to protect.
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
{
|
||||
path: 'settings/secure-send',
|
||||
name: 'secure-send-settings',
|
||||
component: () => import('./views/SecureSendSettings.vue'),
|
||||
// Admin, like every other settings page: this decides whether an
|
||||
// unauthenticated caller may write to the site.
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'tools' }
|
||||
},
|
||||
{
|
||||
path: 'tools/secure-send',
|
||||
name: 'tools-secure-send',
|
||||
component: () => import('./views/SecureSendCreate.vue'),
|
||||
// Reachable by anyone; the SERVER decides who may actually create. A signed
|
||||
// in user needs tools.secrets, and a guest needs the site to have turned on
|
||||
// securesend_allow_guests. The page reads that setting so a guest who
|
||||
// cannot create is told so, instead of being handed a form that 401s.
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
]
|
||||
|
||||
// PUBLIC and OUTSIDE AppLayout. The recipient of a secret usually has no account
|
||||
// here, so this page carries no navigation and asks for no login - the
|
||||
// unguessable token in the path, and the key in the fragment, are what let them
|
||||
// read it. Short path because the whole link gets pasted into a chat message.
|
||||
export const toplevel = [
|
||||
{
|
||||
path: '/s/:token',
|
||||
name: 'secure-send-read',
|
||||
component: () => import('./views/SecureSendRead.vue'),
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
]
|
||||
|
||||
136
plugins/tools/frontend/securesend.js
Normal file
136
plugins/tools/frontend/securesend.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// Client-side crypto for secure send.
|
||||
//
|
||||
// THE KEY NEVER LEAVES THIS FILE'S PROCESS. It is generated here, put in the
|
||||
// URL fragment, and read back out of the fragment on the other side. Browsers
|
||||
// do not transmit fragments, so it reaches the recipient's browser and nowhere
|
||||
// else - not the server, not its logs, not a proxy in between. That is what
|
||||
// makes the stored ciphertext useless to anyone who reads the database.
|
||||
//
|
||||
// AES-GCM because it authenticates as well as encrypts: a tampered ciphertext
|
||||
// fails to decrypt rather than producing plausible rubbish.
|
||||
//
|
||||
// Web Crypto only, no dependency. It is available on every browser the fleet
|
||||
// runs and requires a secure context, which is the right constraint anyway -
|
||||
// the page and its scripts still cross the network even though the key does not.
|
||||
|
||||
const ALGORITHM = 'AES-GCM'
|
||||
const KEY_BITS = 256
|
||||
const IV_BYTES = 12
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = ''
|
||||
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function fromBase64(text) {
|
||||
const binary = atob(text)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// base64url for the fragment: '+' and '/' are legal in a fragment but survive
|
||||
// copy-paste and link rewriting far less reliably than the url-safe alphabet.
|
||||
function toBase64Url(bytes) {
|
||||
return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function fromBase64Url(text) {
|
||||
const padded = text.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
||||
}
|
||||
|
||||
export function cryptoAvailable() {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle
|
||||
}
|
||||
|
||||
/** Encrypt text. Returns the payload for the server AND the key for the link. */
|
||||
export async function sealSecret(plaintext) {
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: ALGORITHM, length: KEY_BITS }, true, ['encrypt', 'decrypt'])
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES))
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv }, key, new TextEncoder().encode(plaintext))
|
||||
const raw = await crypto.subtle.exportKey('raw', key)
|
||||
return {
|
||||
ciphertext: toBase64(ciphertext),
|
||||
iv: toBase64(iv),
|
||||
// Goes in the fragment. Never sent anywhere.
|
||||
fragmentKey: toBase64Url(raw),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt what the server handed back, using the key from the fragment. */
|
||||
export async function openSecret({ ciphertext, iv, fragmentKey }) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', fromBase64Url(fragmentKey),
|
||||
{ name: ALGORITHM, length: KEY_BITS }, false, ['decrypt'])
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv: fromBase64(iv) }, key, fromBase64(ciphertext))
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
// -- Keeping this from becoming a document transfer ---------------------------
|
||||
//
|
||||
// The server can only bound SIZE, because it cannot read what it holds. These
|
||||
// run here, where the plaintext is still visible, and are advisory: they guide
|
||||
// somebody who is about to do the wrong thing without realising. A determined
|
||||
// person can POST directly, which is why the size limit on the server is the
|
||||
// control that actually enforces.
|
||||
|
||||
// Matches MAX_CIPHERTEXT server-side, minus room for base64 expansion and the
|
||||
// GCM tag, so the browser refuses before a round trip rather than after one.
|
||||
export const MAX_PLAINTEXT = 2800
|
||||
|
||||
const MAX_LINES = 20
|
||||
|
||||
// A pasted file, recognised by how it starts. Cheap, high signal.
|
||||
const FILE_SIGNATURES = [
|
||||
['%PDF', 'a PDF'],
|
||||
['PK\x03\x04', 'a zip or Office file'],
|
||||
['{\\rtf', 'an RTF document'],
|
||||
['\x89PNG', 'a PNG image'],
|
||||
// JPEG magic bytes as escapes rather than literals: the naming gate bans
|
||||
// non-ASCII source, and these are bytes rather than text anyway.
|
||||
['\xff\xd8\xff', 'a JPEG image'],
|
||||
['data:', 'a data URI'],
|
||||
]
|
||||
|
||||
/** Why this text should not be sent, or null when it is fine. */
|
||||
export function refuseReason(text) {
|
||||
const value = text || ''
|
||||
if (!value.trim()) return 'Nothing to send yet.'
|
||||
|
||||
if (value.length > MAX_PLAINTEXT) {
|
||||
return `That is ${value.length} characters. This is for passwords and short `
|
||||
+ `text, not documents - the limit is ${MAX_PLAINTEXT}.`
|
||||
}
|
||||
|
||||
for (const [signature, description] of FILE_SIGNATURES) {
|
||||
if (value.startsWith(signature)) {
|
||||
return `That looks like ${description} pasted as text. Secure send is for `
|
||||
+ 'credentials, not file transfer.'
|
||||
}
|
||||
}
|
||||
|
||||
const lines = value.split('\n').length
|
||||
if (lines > MAX_LINES) {
|
||||
return `That is ${lines} lines. A password is one - this looks like a `
|
||||
+ 'document, and secure send is not a file transfer.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a paste that carries FILES rather than text.
|
||||
*
|
||||
* Removes the easiest wrong path - dragging a PDF in, or Ctrl+V on a copied
|
||||
* file - without anyone having to read a rule about it.
|
||||
*/
|
||||
export function pasteCarriesFiles(event) {
|
||||
const items = event?.clipboardData?.items
|
||||
if (!items) return false
|
||||
return Array.from(items).some(item => item.kind === 'file')
|
||||
}
|
||||
@@ -24,11 +24,21 @@ export const TOOLS = [
|
||||
standalone: true,
|
||||
keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'],
|
||||
},
|
||||
{
|
||||
id: 'secure-send',
|
||||
name: 'Secure Send',
|
||||
description: 'Hand somebody a password by a link that works once and then destroys it.',
|
||||
category: 'security',
|
||||
route: '/tools/secure-send',
|
||||
keywords: ['password', 'secret', 'credential', 'share', 'link', 'expire',
|
||||
'one time', 'burn', 'pusher'],
|
||||
},
|
||||
]
|
||||
|
||||
export const CATEGORY_LABELS = {
|
||||
labels: 'Labels & Printing',
|
||||
convert: 'Conversion',
|
||||
security: 'Secrets & Access',
|
||||
network: 'Network',
|
||||
}
|
||||
|
||||
|
||||
251
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
251
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Secure Send</h1>
|
||||
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card intro">
|
||||
<p>
|
||||
Hand somebody a password without leaving it in a chat window. The text is
|
||||
encrypted in this browser and the key travels in the link, never to the
|
||||
server - so nobody here can read what you send, including an administrator.
|
||||
</p>
|
||||
<p class="muted">
|
||||
The link works once, then the secret is destroyed. For passwords and short
|
||||
text only, not documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- A guest at a site that has not opened guest creation. Told here rather
|
||||
than after typing a password into a form that was always going to be
|
||||
refused. -->
|
||||
<div v-if="supported && !mayCreate" class="card">
|
||||
<p>Signing in is needed to create a secret link at this site.</p>
|
||||
<p class="muted">
|
||||
Receiving one never needs an account - if somebody sent you a link, open
|
||||
that instead.
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">Sign in</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="card control-note">
|
||||
This browser cannot encrypt in page (Web Crypto is unavailable, which
|
||||
usually means the site is not being served over HTTPS). Secure send needs
|
||||
it, because the whole point is that the server never sees your text.
|
||||
</div>
|
||||
|
||||
<template v-else-if="mayCreate && !link">
|
||||
<div class="card">
|
||||
<label class="field">
|
||||
<span class="field-label">Secret</span>
|
||||
<textarea
|
||||
v-model="plaintext"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="Password, connection string, short note..."
|
||||
@paste="onPaste"
|
||||
></textarea>
|
||||
</label>
|
||||
<p class="counter" :class="{ over: plaintext.length > MAX_PLAINTEXT }">
|
||||
{{ plaintext.length }} / {{ MAX_PLAINTEXT }} characters
|
||||
</p>
|
||||
<p v-if="refusal" class="control-note">{{ refusal }}</p>
|
||||
|
||||
<div class="options">
|
||||
<label class="field">
|
||||
<span class="field-label">Expires after</span>
|
||||
<select v-model.number="expiryhours" class="form-control">
|
||||
<option :value="1">1 hour</option>
|
||||
<option :value="8">8 hours</option>
|
||||
<option :value="24">1 day</option>
|
||||
<option :value="72">3 days</option>
|
||||
<option :value="168">7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Can be opened</span>
|
||||
<input
|
||||
v-model.number="views"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="1"
|
||||
:max="MAX_VIEWS"
|
||||
/>
|
||||
<span class="field-hint">
|
||||
{{ views === 1 ? 'Once, then destroyed' : views + ' times, then destroyed' }}
|
||||
(1 to {{ MAX_VIEWS }})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="busy || !!refusal" @click="createLink">
|
||||
{{ busy ? 'Encrypting...' : 'Create link' }}
|
||||
</button>
|
||||
<p v-if="error" class="control-note">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<h3>Your link</h3>
|
||||
<p class="muted">
|
||||
Send this to the person who needs it. It opens
|
||||
{{ views === 1 ? 'once' : views + ' times' }} and expires {{ expiryLabel }}.
|
||||
</p>
|
||||
<div class="link-row">
|
||||
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
|
||||
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="control-hint">
|
||||
This is the only time the link is shown - it is not stored anywhere you can
|
||||
look it up, and the key in it exists nowhere else.
|
||||
</p>
|
||||
<button class="btn btn-secondary" @click="reset">Send another</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { toolsApi } from '../api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { getSiteBaseUrl, getSetting } from '@/utils/siteSettings'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
|
||||
} from '../securesend'
|
||||
|
||||
const supported = cryptoAvailable()
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Whether THIS visitor can create one. A signed-in user always sees the form -
|
||||
// the server still enforces tools.secrets and says so if they lack it, which is
|
||||
// a different message from "sign in". A guest sees it only where the site has
|
||||
// opened guest creation.
|
||||
const mayCreate = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isAuthenticated) return
|
||||
const allowed = await getSetting('securesend_allow_guests', 'false')
|
||||
mayCreate.value = String(allowed).toLowerCase() === 'true'
|
||||
})
|
||||
|
||||
// Matches the server's ceiling. A free number rather than a dropdown because
|
||||
// "how many people need this password" is the sender's business - a shift
|
||||
// handover is not the same as one colleague on a call.
|
||||
const MAX_VIEWS = 100
|
||||
|
||||
const plaintext = ref('')
|
||||
const expiryhours = ref(24)
|
||||
const views = ref(1)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const link = ref('')
|
||||
const copied = ref(false)
|
||||
const linkInput = ref(null)
|
||||
|
||||
// Only nag once there is something to nag about: an empty box is not a mistake.
|
||||
const refusal = computed(() => (plaintext.value ? refuseReason(plaintext.value) : null))
|
||||
|
||||
const expiryLabel = computed(() => {
|
||||
const hours = expiryhours.value
|
||||
if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
||||
const days = hours / 24
|
||||
return `in ${days} day${days === 1 ? '' : 's'}`
|
||||
})
|
||||
|
||||
function onPaste(event) {
|
||||
// A pasted FILE is the easiest wrong path into a tool that is not a file
|
||||
// transfer. Refuse it here rather than letting its bytes become "text".
|
||||
if (pasteCarriesFiles(event)) {
|
||||
event.preventDefault()
|
||||
error.value = 'Secure send takes text, not files. Paste the password itself.'
|
||||
}
|
||||
}
|
||||
|
||||
async function createLink() {
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const sealed = await sealSecret(plaintext.value)
|
||||
const response = await toolsApi.createSecret({
|
||||
ciphertext: sealed.ciphertext,
|
||||
iv: sealed.iv,
|
||||
expiryhours: expiryhours.value,
|
||||
views: views.value,
|
||||
})
|
||||
const token = response.data.data.token
|
||||
|
||||
// THE SITE'S CONFIGURED URL, not the one this browser happens to be using.
|
||||
// site_base_url exists for exactly this - "used for QR codes and any
|
||||
// absolute link the app emits" - because somebody creating a secret may
|
||||
// have reached the app by an IP, a short hostname, or a name that only
|
||||
// resolves inside one VLAN, and the recipient has to open the link from
|
||||
// wherever they are. It falls back to the browsing origin when a site has
|
||||
// not set one.
|
||||
//
|
||||
// withBase for the path: a site served under /shopdb or /ops carries that
|
||||
// in front of every route, and leaving it off produced a 404 from IIS for a
|
||||
// secret that existed - with the sender shown a link that could never work.
|
||||
// site_base_url is DOCUMENTED as scheme + host, but sites really do set the
|
||||
// full base including the subpath - and appending the mount to one that
|
||||
// already carries it produced /shopdb/shopdb/s/<token>. Tolerate both
|
||||
// rather than trusting the description: add the mount only when it is not
|
||||
// there already.
|
||||
const configured = (await getSiteBaseUrl()).replace(/\/+$/, '')
|
||||
const mount = withBase('/').replace(/\/+$/, '')
|
||||
const needsMount = mount && !configured.endsWith(mount)
|
||||
|
||||
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
|
||||
// recipient and nowhere else.
|
||||
link.value =
|
||||
`${configured}${needsMount ? mount : ''}/s/${token}#${sealed.fragmentKey}`
|
||||
// Drop the plaintext as soon as it is sealed: no reason to keep it in memory
|
||||
// behind the link that replaced it.
|
||||
plaintext.value = ''
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.data?.error?.message || 'Could not create the link.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
// Clipboard access is refused in some hardened configurations. Select the
|
||||
// text instead so Ctrl+C still works rather than leaving a dead button.
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
linkInput.value?.select()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
link.value = ''
|
||||
error.value = ''
|
||||
copied.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intro p { margin: 0 0 0.5rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field-label { display: block; font-size: 0.8125rem; color: var(--text-light); margin-bottom: 4px; }
|
||||
.options { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.options .field { flex: 1 1 12rem; }
|
||||
.counter { font-size: 0.8125rem; color: var(--text-light); margin: 0 0 0.5rem; }
|
||||
.field-hint { display: block; font-size: 0.8125rem; color: var(--text-light); margin-top: 4px; }
|
||||
.counter.over { color: var(--warning); font-weight: 600; }
|
||||
.control-note { color: var(--warning); font-size: 0.875rem; margin: 8px 0; }
|
||||
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 12px; }
|
||||
.link-row { display: flex; gap: 8px; align-items: center; margin: 0.5rem 0; }
|
||||
.link-row .form-control { flex: 1; }
|
||||
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }
|
||||
</style>
|
||||
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="reveal-page">
|
||||
<div class="reveal-card card">
|
||||
<h1>A secret was shared with you</h1>
|
||||
|
||||
<!-- CHECKING, not revealing. The page asks whether the link is still live
|
||||
before anyone clicks, so it can say what will happen rather than
|
||||
spending the only view to find out. -->
|
||||
<p v-if="state === 'checking'" class="muted">Checking the link...</p>
|
||||
|
||||
<template v-else-if="state === 'ready'">
|
||||
<p>
|
||||
Opening this destroys it. Make sure you can save or use the secret now -
|
||||
it cannot be shown twice, and nobody can recover it for you.
|
||||
</p>
|
||||
<!-- An explicit click, never automatic. Mail and chat scanners fetch
|
||||
links to check them; a page that revealed on load would burn the
|
||||
secret before its recipient ever saw it. -->
|
||||
<button class="btn btn-primary" :disabled="busy" @click="reveal">
|
||||
{{ busy ? 'Opening...' : 'Show me the secret' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'revealed'">
|
||||
<p class="muted">Here it is. It is gone from the server now.</p>
|
||||
<pre class="secret">{{ plaintext }}</pre>
|
||||
<button class="btn btn-primary" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'nokey'">
|
||||
<p class="problem">This link is missing its key.</p>
|
||||
<p class="muted">
|
||||
The part after the # is what decrypts the secret, and it is not here.
|
||||
Some chat and mail systems trim it when they rewrite links. Ask the
|
||||
sender to share the link again - by a route that keeps it whole, or
|
||||
pasted as plain text - and to create a new secret if this one has since
|
||||
expired.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'undecryptable'">
|
||||
<p class="problem">The secret could not be decrypted.</p>
|
||||
<p class="muted">
|
||||
The link opened, but the key in it does not match. That usually means
|
||||
the link was altered in transit. The secret has now been used up, so
|
||||
ask the sender for a new one.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="problem">This link is not valid.</p>
|
||||
<p class="muted">
|
||||
It may have been opened already, or it may have expired. Secrets are
|
||||
destroyed once read, so a link only works the number of times the
|
||||
sender chose. Ask them for a new one.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toolsApi } from '../api'
|
||||
import { openSecret, cryptoAvailable } from '../securesend'
|
||||
|
||||
const route = useRoute()
|
||||
const state = ref('checking')
|
||||
const plaintext = ref('')
|
||||
const busy = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
const token = route.params.token
|
||||
// The key lives in the fragment, which the router does not parse and the server
|
||||
// never receives. Read it off the raw location.
|
||||
const fragmentKey = (window.location.hash || '').replace(/^#/, '')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!cryptoAvailable()) {
|
||||
state.value = 'undecryptable'
|
||||
return
|
||||
}
|
||||
if (!fragmentKey) {
|
||||
state.value = 'nokey'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await toolsApi.peekSecret(token)
|
||||
state.value = response.data.data.available ? 'ready' : 'gone'
|
||||
} catch (err) {
|
||||
state.value = 'gone'
|
||||
}
|
||||
})
|
||||
|
||||
async function reveal() {
|
||||
busy.value = true
|
||||
try {
|
||||
const response = await toolsApi.redeemSecret(token)
|
||||
const { ciphertext, iv } = response.data.data
|
||||
plaintext.value = await openSecret({ ciphertext, iv, fragmentKey })
|
||||
state.value = 'revealed'
|
||||
} catch (err) {
|
||||
// A failed DECRYPT is different from a missing secret, and the difference
|
||||
// matters to the person reading: one means the link was mangled, the other
|
||||
// means it was already used. The secret is spent either way.
|
||||
state.value = err.response ? 'gone' : 'undecryptable'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plaintext.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
copied.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone: this page is opened by somebody who may have no account here, so
|
||||
it carries no navigation and nothing to click into. */
|
||||
.reveal-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.reveal-card { max-width: 32rem; width: 100%; padding: 2rem; }
|
||||
.reveal-card h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.problem { color: var(--warning); font-weight: 600; margin: 0 0 0.5rem; }
|
||||
|
||||
.secret {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.95rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
77
plugins/tools/frontend/views/SecureSendSettings.vue
Normal file
77
plugins/tools/frontend/views/SecureSendSettings.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Secure Send</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card loading">Loading...</div>
|
||||
|
||||
<div v-else class="card">
|
||||
<label class="switch-row">
|
||||
<input v-model="allowGuests" type="checkbox" />
|
||||
<span>
|
||||
<strong>Let people without an account create links</strong>
|
||||
<span class="hint">
|
||||
Off by default. With it off, creating a link needs a login and the
|
||||
<code>tools.secrets</code> permission. Receiving one never needs an
|
||||
account either way.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- The cost, stated where the decision is made rather than in a document
|
||||
nobody opens. -->
|
||||
<p class="control-note" v-if="allowGuests">
|
||||
A link created by a guest has no creator recorded. The audit trail can
|
||||
say when it was made and from where, but never who made it.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="saved" class="saved">Saved</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
|
||||
const KEY = 'securesend_allow_guests'
|
||||
|
||||
const allowGuests = ref(false)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'tools' })
|
||||
const rows = response.data.data || []
|
||||
const row = rows.find(setting => setting.key === KEY)
|
||||
allowGuests.value = String(row?.value).toLowerCase() === 'true'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.update(KEY, allowGuests.value ? 'true' : 'false')
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch-row { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 1rem; }
|
||||
.switch-row span { display: block; }
|
||||
.hint { display: block; color: var(--text-light); font-size: 0.875rem; margin-top: 4px; }
|
||||
.control-note { color: var(--warning); font-size: 0.875rem; margin: 0 0 1rem; }
|
||||
.saved { margin-left: 10px; color: var(--text-light); font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -12,6 +12,11 @@ plugin's tables, so the foreign key to the core users table cannot resolve at
|
||||
CreateTable-compile time.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -23,7 +28,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'securesendsecrets',
|
||||
sa.Column('secretid', sa.Integer(), nullable=False),
|
||||
# 128 bits of randomness as hex. With no login on the read side this is
|
||||
@@ -47,13 +52,13 @@ def upgrade():
|
||||
)
|
||||
# Unique, because the token is looked up on every read and two rows sharing
|
||||
# one would make a link ambiguous.
|
||||
op.create_index('ixsecuresendsecretstoken', 'securesendsecrets',
|
||||
['token'], unique=True)
|
||||
create_index_if_missing('ixsecuresendsecretstoken', 'securesendsecrets',
|
||||
['token'], unique=True)
|
||||
# Expiry sweeps scan on this.
|
||||
op.create_index('ixsecuresendsecretsexpiresat', 'securesendsecrets',
|
||||
['expiresat'])
|
||||
op.create_index('ixsecuresendsecretscreatedby', 'securesendsecrets',
|
||||
['createdbyuserid'])
|
||||
create_index_if_missing('ixsecuresendsecretsexpiresat', 'securesendsecrets',
|
||||
['expiresat'])
|
||||
create_index_if_missing('ixsecuresendsecretscreatedby', 'securesendsecrets',
|
||||
['createdbyuserid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -54,21 +54,66 @@ class ToolsPlugin(BasePlugin):
|
||||
return [SecureSecret]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""One permission, on CREATING a secret only.
|
||||
"""Who may CREATE a secret, when guests are not allowed.
|
||||
|
||||
There is deliberately no read permission: the recipient usually has no
|
||||
account here, and the unguessable token is what authorises the read.
|
||||
Gating that side would make the tool useless for the case it exists for -
|
||||
handing somebody a credential while you are on a call with them.
|
||||
The default access model: signed in, and holding this. Reading needs no
|
||||
permission at all - the recipient usually has no account here and the
|
||||
unguessable token is the authorisation.
|
||||
|
||||
No delete permission either. A secret is spent by being read or by
|
||||
expiring, and both remove the row; a hand-delete route would only add a
|
||||
way to destroy the record of a secret that had not been read yet.
|
||||
A site that turns on securesend_allow_guests bypasses this for people
|
||||
with no account; it still applies to anyone who IS signed in, so a
|
||||
member without it cannot create one while a guest can. That is
|
||||
deliberate: opening the door to guests is a decision about the door, not
|
||||
about revoking what a role does or does not hold.
|
||||
"""
|
||||
return [
|
||||
('tools.secrets', 'Create secure send links', 'tools'),
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""The one switch this plugin owns.
|
||||
|
||||
Public so the create page can read it BEFORE login and show a guest the
|
||||
form rather than a sign-in wall. It says nothing sensitive: whether the
|
||||
door is open is visible to anyone who tries the door.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'key': 'securesend_allow_guests',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'tools',
|
||||
'public': True,
|
||||
'description': ('Let people with no account create secure send '
|
||||
'links. Off by default: a guest secret has no '
|
||||
'creator, so the audit trail can say when and '
|
||||
'from where but never who. Receiving a secret '
|
||||
'never needs an account either way.'),
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_cards(self) -> List[dict]:
|
||||
"""A page for the one switch this plugin owns (ADR-010).
|
||||
|
||||
get_settings_defaults SEEDS the row; this is what gives somebody a place
|
||||
to change it. Declaring only the first left the setting in the database
|
||||
with no way to reach it, which is indistinguishable from the feature not
|
||||
existing.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'Tools',
|
||||
'to': '/settings/secure-send',
|
||||
# From the settings iconMap; an unknown key falls back to a
|
||||
# puzzle piece, which reads as "nobody chose this".
|
||||
'icon': 'wrench',
|
||||
'title': 'Secure Send',
|
||||
'description': 'Whether people without an account can create '
|
||||
'one-view password links',
|
||||
'position': 60,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset..." />
|
||||
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset number, machine..." />
|
||||
<span class="result-count">{{ filteredItems.length }} of {{ items.length }}</span>
|
||||
</div>
|
||||
|
||||
@@ -186,6 +186,7 @@ import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import MachineMapChip from '../components/MachineMapChip.vue'
|
||||
import { matchesWarrantySearch } from './warrantySearch'
|
||||
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
@@ -197,16 +198,11 @@ const search = ref('')
|
||||
const page = ref(1)
|
||||
const perPage = 25
|
||||
|
||||
// Client-side search across vendor / service level / tag / linked asset numbers.
|
||||
const filteredItems = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return items.value
|
||||
return items.value.filter(w =>
|
||||
(w.vendor || '').toLowerCase().includes(term) ||
|
||||
(w.servicelevel || '').toLowerCase().includes(term) ||
|
||||
(w.servicetag || '').toLowerCase().includes(term) ||
|
||||
(w.assets || []).some(a => (a.assetnumber || '').toLowerCase().includes(term)))
|
||||
})
|
||||
// Client-side search. The field list lives in warrantySearch.js, where it is
|
||||
// covered by tests - the machine number was rendered in this table for a long
|
||||
// time before anything let you search for it.
|
||||
const filteredItems = computed(() =>
|
||||
items.value.filter(w => matchesWarrantySearch(w, search.value)))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / perPage)))
|
||||
const paginatedItems = computed(() => filteredItems.value.slice((page.value - 1) * perPage, page.value * perPage))
|
||||
|
||||
|
||||
44
plugins/warranty/frontend/views/warrantySearch.js
Normal file
44
plugins/warranty/frontend/views/warrantySearch.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// What the warranty list's search box matches against.
|
||||
//
|
||||
// Pulled out of WarrantiesList.vue so the field list is somewhere a test can
|
||||
// see it. It went wrong the way a field list usually does: silently, by a
|
||||
// column being added to the table and not to the filter.
|
||||
//
|
||||
// THE MACHINE NUMBER IS THE POINT. _related_machine in the warranty API exists
|
||||
// precisely because "a shopfloor PC is bought, warranted and replaced as a PC,
|
||||
// but it is FOUND by the machine it drives - nobody walks the floor looking for
|
||||
// an asset number". The payload carries it, the table renders it in a chip, and
|
||||
// the filter did not look at it - so the one identifier the feature was built
|
||||
// around was the one thing the search box could not find.
|
||||
|
||||
/**
|
||||
* Does this warranty match the search term?
|
||||
*
|
||||
* @param {object} warranty - a row from GET /api/warranties
|
||||
* @param {string} term - the raw search box contents
|
||||
* @returns {boolean} true when the term is empty or any searched field contains it
|
||||
*/
|
||||
export function matchesWarrantySearch(warranty, term) {
|
||||
const needle = (term || '').trim().toLowerCase()
|
||||
if (!needle) return true
|
||||
if (!warranty) return false
|
||||
|
||||
const has = (value) => String(value ?? '').toLowerCase().includes(needle)
|
||||
|
||||
if (has(warranty.vendor) || has(warranty.servicelevel) || has(warranty.servicetag)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (warranty.assets || []).some((asset) => {
|
||||
if (has(asset?.assetnumber)) return true
|
||||
// The related machine is optional: a warranty on something that drives no
|
||||
// machine has none, and a machine that was never linked leaves it null.
|
||||
const machine = asset?.machine
|
||||
return !!machine && (has(machine.machinenumber) || has(machine.name))
|
||||
})
|
||||
}
|
||||
|
||||
/** The fields above, in the order the placeholder should name them. */
|
||||
export const WARRANTY_SEARCH_FIELDS = [
|
||||
'vendor', 'service level', 'tag', 'asset number', 'machine',
|
||||
]
|
||||
79
plugins/warranty/frontend/views/warrantySearch.spec.js
Normal file
79
plugins/warranty/frontend/views/warrantySearch.spec.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { matchesWarrantySearch } from './warrantySearch'
|
||||
|
||||
// Reported: the warranty page could not be searched by machine number.
|
||||
//
|
||||
// The number was in the payload (_related_machine puts it there) and rendered
|
||||
// in the table as a chip, but the filter looked only at vendor, service level,
|
||||
// service tag and asset number. So the identifier the feature was built around
|
||||
// - a PC is warranted as a PC but FOUND by the machine it drives - was the one
|
||||
// thing the box could not find.
|
||||
|
||||
function warranty(overrides = {}) {
|
||||
return {
|
||||
vendor: 'Dell',
|
||||
servicelevel: 'ProSupport',
|
||||
servicetag: 'ABC1234',
|
||||
assets: [{
|
||||
assetnumber: 'PC-00912',
|
||||
name: 'Bay 3105 workstation',
|
||||
machine: { machinenumber: '3016', name: 'Makino a61nx' },
|
||||
}],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('matchesWarrantySearch', () => {
|
||||
it('matches on the machine number', () => {
|
||||
// The reported case.
|
||||
expect(matchesWarrantySearch(warranty(), '3016')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches on the machine name', () => {
|
||||
expect(matchesWarrantySearch(warranty(), 'makino')).toBe(true)
|
||||
})
|
||||
|
||||
it('still matches the fields it always did', () => {
|
||||
expect(matchesWarrantySearch(warranty(), 'dell')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), 'prosupport')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), 'abc1234')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), 'pc-00912')).toBe(true)
|
||||
})
|
||||
|
||||
it('is case insensitive and ignores surrounding space', () => {
|
||||
expect(matchesWarrantySearch(warranty(), ' MAKINO ')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match an unrelated term', () => {
|
||||
expect(matchesWarrantySearch(warranty(), '9999')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps every row when the term is empty', () => {
|
||||
expect(matchesWarrantySearch(warranty(), '')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), ' ')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles a warranty whose asset drives no machine', () => {
|
||||
// machine is null whenever the covered asset has no controls link - a
|
||||
// printer, or a PC nobody linked yet. It must not throw.
|
||||
const row = warranty({ assets: [{ assetnumber: 'PR-42', machine: null }] })
|
||||
expect(matchesWarrantySearch(row, 'PR-42')).toBe(true)
|
||||
expect(matchesWarrantySearch(row, '3016')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles a warranty with no linked assets at all', () => {
|
||||
const row = warranty({ assets: [] })
|
||||
expect(matchesWarrantySearch(row, 'dell')).toBe(true)
|
||||
expect(matchesWarrantySearch(row, '3016')).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates missing fields rather than matching everything', () => {
|
||||
// String(undefined) is 'undefined', which would make a search for 'und'
|
||||
// match every row with an empty field.
|
||||
const row = { vendor: null, servicelevel: undefined, assets: null }
|
||||
expect(matchesWarrantySearch(row, 'und')).toBe(false)
|
||||
expect(matchesWarrantySearch(row, 'null')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user