Wave 2 of the remediation plan. Eight findings from the feature test run were the same defect twice: a write that reports success while storing something other than what was sent. The first half is a date parser that swallowed its own error. Two plugins carried an identical private _parse_date returning None on ValueError, so an unparseable date and a deliberately cleared one produced the same write. PUT /api/warranty/<id> with enddate '30-06-2027' answered 200 "Warranty updated" and stored NULL, dropping the record out of every expiry surface on the strength of a typo; the measuring-tool calibration date did the same and flipped the tool to 'unknown', out of the calibration report and the map overlay. shopdb/utils/datevalues.py now holds one strict parser on the contract surface: an explicitly empty value clears the field, anything that is not a date is refused with a 400 naming the field, and those are different intents with different answers. The vendor-lookup paths keep a lenient wrapper on purpose - a provider changing its date shape must not fail the refresh of an otherwise good record, and no person typed that value. The second half is why the truncation findings were possible at all. MySQL was running non-strict: sql_mode on the 5.6 server is NO_ENGINE_SUBSTITUTION with no STRICT_TRANS_TABLES, so it accepted writes it could not store and adjusted them. A 257-character username became its first 100 characters (and the next distinct one then collided with it); an over-long asset number was cut at 50; a malformed releasedate became '0000-00-00' and permanently broke every read of that application. All with a 201. The mode is now set on the CONNECTION rather than left to the server, so a site whose my.cnf disagrees still gets it. That trades silent corruption for a DataError, which uncaught is a 500 - and a 500 says the server is broken when the truth is that the value does not fit. An error handler maps it to a 400 naming the column where MySQL names one. Verified by hand against MySQL 5.6: POST /api/users with a 257-character username now answers 400 "That value is too long for username." and stores nothing, where it previously stored a truncated row and answered 201. Twelve tests. The date guard is verified by planting: restore the swallowing parser and four of them fail. The DataError tests raise the error directly because the suite runs on SQLite, which does not enforce VARCHAR length at all - there is no value that would trigger it there, which is precisely why this went unnoticed. Scope: product
156 lines
6.5 KiB
Python
156 lines
6.5 KiB
Python
"""Tests for the warranty proof-of-cover document.
|
|
|
|
A provider lookup answers whether a unit is covered; it does not produce the
|
|
invoice or the extended-warranty certificate. Those had nowhere to live, so
|
|
they stayed in somebody's mailbox. These pin the parts that are easy to get
|
|
wrong: the document is authenticated on the way out, one per warranty, and the
|
|
vendor's own filename survives so a download is recognisable.
|
|
"""
|
|
|
|
import io
|
|
|
|
from plugins.warranty.models import Warranty
|
|
|
|
|
|
def _warranty(db, vendor='Dell'):
|
|
w = Warranty(vendor=vendor, servicetag='ABC123', provider='manual')
|
|
db.session.add(w)
|
|
db.session.commit()
|
|
return w
|
|
|
|
|
|
def test_upload_sets_url_and_keeps_the_original_filename(client, db, auth_headers):
|
|
w = _warranty(db)
|
|
|
|
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
|
data={'file': (io.BytesIO(b'%PDF-1.4 invoice'),
|
|
'Dell invoice 4471.pdf')},
|
|
content_type='multipart/form-data',
|
|
headers=auth_headers)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
body = response.get_json()['data']
|
|
assert body['proofurl'] == f'/api/warranty/proof/warranty-{w.warrantyid}.pdf'
|
|
assert body['prooffilename'] == 'Dell invoice 4471.pdf'
|
|
|
|
|
|
def test_download_needs_a_token_and_uses_the_vendor_filename(client, db, auth_headers):
|
|
"""An invoice carries pricing and a service tag - not open like an asset photo."""
|
|
w = _warranty(db)
|
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
|
data={'file': (io.BytesIO(b'%PDF-1.4 invoice'), 'Dell invoice 4471.pdf')},
|
|
content_type='multipart/form-data', headers=auth_headers)
|
|
path = f'/api/warranty/proof/warranty-{w.warrantyid}.pdf'
|
|
|
|
assert client.get(path).status_code == 401
|
|
|
|
authed = client.get(path, headers=auth_headers)
|
|
assert authed.status_code == 200
|
|
assert authed.data == b'%PDF-1.4 invoice'
|
|
disposition = authed.headers.get('Content-Disposition', '')
|
|
assert 'attachment' in disposition
|
|
assert 'Dell invoice 4471.pdf' in disposition
|
|
|
|
|
|
def test_rejects_an_unsupported_type(client, db, auth_headers):
|
|
w = _warranty(db)
|
|
|
|
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
|
data={'file': (io.BytesIO(b'MZ'), 'setup.exe')},
|
|
content_type='multipart/form-data',
|
|
headers=auth_headers)
|
|
|
|
assert response.status_code == 400
|
|
assert 'Unsupported document type' in response.get_data(as_text=True)
|
|
|
|
|
|
def test_rejects_an_oversize_document(client, db, auth_headers, monkeypatch):
|
|
from plugins.warranty.api import routes
|
|
monkeypatch.setattr(routes, 'MAX_PROOF_BYTES', 512)
|
|
w = _warranty(db)
|
|
|
|
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
|
data={'file': (io.BytesIO(b'x' * 2048), 'scan.pdf')},
|
|
content_type='multipart/form-data',
|
|
headers=auth_headers)
|
|
|
|
assert response.status_code == 400
|
|
assert 'the limit is' in response.get_data(as_text=True)
|
|
|
|
|
|
def test_replacing_keeps_one_document_per_warranty(client, db, auth_headers):
|
|
import glob
|
|
import os
|
|
from flask import current_app
|
|
w = _warranty(db)
|
|
|
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
|
data={'file': (io.BytesIO(b'first'), 'scan.png')},
|
|
content_type='multipart/form-data', headers=auth_headers)
|
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
|
data={'file': (io.BytesIO(b'%PDF second'), 'invoice.pdf')},
|
|
content_type='multipart/form-data', headers=auth_headers)
|
|
|
|
proofdir = os.path.join(current_app.instance_path, 'warrantyproofs')
|
|
files = glob.glob(os.path.join(proofdir, f'warranty-{w.warrantyid}.*'))
|
|
assert len(files) == 1 and files[0].endswith('.pdf')
|
|
|
|
|
|
def test_removing_clears_both_columns(client, db, auth_headers):
|
|
w = _warranty(db)
|
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
|
data={'file': (io.BytesIO(b'%PDF'), 'invoice.pdf')},
|
|
content_type='multipart/form-data', headers=auth_headers)
|
|
|
|
response = client.delete(f'/api/warranty/{w.warrantyid}/proof',
|
|
headers=auth_headers)
|
|
|
|
body = response.get_json()['data']
|
|
assert body['proofurl'] is None
|
|
assert body['prooffilename'] is None
|
|
|
|
|
|
# --- a date that is not a date -----------------------------------------------
|
|
#
|
|
# PUT /api/warranty/<id> with an unparseable enddate answered 200 "Warranty
|
|
# updated" and stored NULL, which drops the record out of every expiry surface
|
|
# it feeds. The local _parse_date swallowed the ValueError, so a typo and a
|
|
# deliberate clear produced the same write.
|
|
|
|
def _warranty_via_api(client, auth_headers, **fields):
|
|
payload = {'vendor': 'Acme', 'servicetag': 'WD-1', 'enddate': '2027-06-30'}
|
|
payload.update(fields)
|
|
created = client.post('/api/warranty', headers=auth_headers, json=payload)
|
|
assert created.status_code == 201, created.get_json()
|
|
return created.get_json()['data']['warrantyid']
|
|
|
|
|
|
def test_a_mistyped_end_date_is_refused_not_stored_as_null(client, db,
|
|
auth_headers):
|
|
warrantyid = _warranty_via_api(client, auth_headers)
|
|
|
|
refused = client.put(f'/api/warranty/{warrantyid}', headers=auth_headers,
|
|
json={'enddate': '30-06-2027'})
|
|
assert refused.status_code == 400, refused.get_json()
|
|
assert 'enddate' in refused.get_json()['data']['error']['message']
|
|
|
|
kept = db.session.get(Warranty, warrantyid)
|
|
assert kept.enddate is not None, 'the stored cover date must survive a typo'
|
|
assert str(kept.enddate) == '2027-06-30'
|
|
|
|
|
|
def test_an_explicitly_empty_end_date_still_clears_it(client, db, auth_headers):
|
|
warrantyid = _warranty_via_api(client, auth_headers, servicetag='WD-2')
|
|
cleared = client.put(f'/api/warranty/{warrantyid}', headers=auth_headers,
|
|
json={'enddate': ''})
|
|
assert cleared.status_code == 200, cleared.get_json()
|
|
assert db.session.get(Warranty, warrantyid).enddate is None
|
|
|
|
|
|
def test_a_mistyped_date_is_refused_at_create_too(client, db, auth_headers):
|
|
refused = client.post('/api/warranty', headers=auth_headers,
|
|
json={'vendor': 'Acme', 'servicetag': 'WD-3',
|
|
'startdate': 'yesterday'})
|
|
assert refused.status_code == 400, refused.get_json()
|
|
assert Warranty.query.filter_by(servicetag='WD-3').first() is None
|