-- --------------------------------------------------------------------------- -- End-to-end check of the account and character data path. -- -- The first run creates a marker account. Every later run finds it already -- there and verifies it came back byte-for-byte, which is what "persists -- between restarts" actually means. Run `selftest_reset` to clear it. -- --------------------------------------------------------------------------- local MARKER = 'zz_selftest' local PASSWORD = 'correct horse 7' local passed, failed = 0, 0 local function check(name, ok, detail) if ok then passed = passed + 1 print(('^2 PASS^7 %s'):format(name)) else failed = failed + 1 print(('^1 FAIL^7 %s ^1%s^7'):format(name, detail or '')) end end local function hash(pw) local p = promise.new() exports.rp_core:hashPassword(pw, function(h, err) p:resolve({ h = h, err = err }) end) local r = Citizen.Await(p) return r.h, r.err end local function verify(pw, stored) local p = promise.new() exports.rp_core:verifyPassword(pw, stored, function(ok, err) p:resolve({ ok = ok, err = err }) end) return Citizen.Await(p).ok end CreateThread(function() if not DB.WaitReady(30000) then print('^1[selftest] database never became ready^7') return end print('^5[selftest]^7 ---- account + character data path ----') -- 1. password hashing ---------------------------------------------------- local h, herr = hash(PASSWORD) check('scrypt hash produced', type(h) == 'string' and h:sub(1, 7) == 'scrypt$', tostring(herr)) check('correct password verifies', verify(PASSWORD, h)) check('wrong password rejected', not verify(PASSWORD .. 'x', h)) check('empty password rejected', not verify('', h)) local h2 = hash(PASSWORD) check('same password hashes differently (salted)', h ~= h2) check('second hash also verifies', verify(PASSWORD, h2)) check('corrupt hash rejected safely', not verify(PASSWORD, 'not-a-hash')) -- 2. the account row ------------------------------------------------------ local existing = DB.Single('SELECT id, password_hash, created_at FROM accounts WHERE username = ?', { MARKER }) local firstRun = existing == nil local accountId if firstRun then accountId = DB.Insert( 'INSERT INTO accounts (username, password_hash, license) VALUES (?, ?, ?)', { MARKER, h, 'selftest-license' }) check('account created', type(accountId) == 'number' and accountId > 0) print('^3[selftest]^7 marker account created - restart the server to prove persistence') else accountId = existing.id check('account survived restart', true) check('stored hash still verifies after restart', verify(PASSWORD, existing.password_hash)) print(('^5[selftest]^7 marker account has existed since %s'):format(tostring(existing.created_at))) end -- 3. the character row, including the appearance JSON --------------------- local appearance = Appearance.Sanitise({ model = 'f', parents = { father = 12, mother = 33, shapeMix = 0.62, skinMix = 0.25 }, hair = { style = 14, colour = 9, highlight = 2 }, features = { ['0'] = 0.4, ['13'] = -0.75 }, overlays = { eyebrows = { index = 5, opacity = 0.8, colour = 3 } }, components = { jacket = { drawable = 42, texture = 3 } }, }) local charId = DB.Scalar('SELECT id FROM characters WHERE account_id = ? AND deleted = 0 LIMIT 1', { accountId }) if not charId then charId = DB.Insert([[ INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender, backstory, appearance) VALUES (?, 0, ?, ?, ?, 'f', ?, ?) ]], { accountId, 'Zztest', 'Persistence', '1991-04-18', 'Created by the self test.', json.encode(appearance) }) check('character created', type(charId) == 'number' and charId > 0) else check('character survived restart', true) end local row = DB.Single('SELECT * FROM characters WHERE id = ?', { charId }) check('character reads back', row ~= nil) if row then local back = json.decode(row.appearance) check('appearance JSON round trip', json.encode(Appearance.Sanitise(back)) == json.encode(appearance), 'appearance differs after a round trip') check('float precision kept', math.abs(back.parents.shapeMix - 0.62) < 0.0001, tostring(back.parents.shapeMix)) check('negative feature kept', math.abs(back.features['13'] + 0.75) < 0.0001, tostring(back.features['13'])) check('starting balances applied', tonumber(row.cash) == Config.Money.startingCash and tonumber(row.bank) == Config.Money.startingBank, ('cash=%s bank=%s'):format(tostring(row.cash), tostring(row.bank))) end -- 4. the constraints the session code relies on --------------------------- local dupe, dupeErr = DB.Insert( 'INSERT INTO accounts (username, password_hash) VALUES (?, ?)', { MARKER, h }) check('duplicate username refused by the database', dupe == nil, tostring(dupe)) local dupeName = DB.Insert([[ INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender) VALUES (?, 1, 'Zztest', 'Persistence', '1991-04-18', 'f') ]], { accountId }) check('duplicate character name refused by the database', dupeName == nil) local orphan = DB.Insert([[ INSERT INTO characters (account_id, slot, first_name, last_name, dob, gender) VALUES (999999, 0, 'Zzorphan', 'Nobody', '1991-04-18', 'm') ]], {}) check('character cannot reference a missing account', orphan == nil) print(('^5[selftest]^7 ==== %d passed, %d failed ===='):format(passed, failed)) if failed == 0 then print(firstRun and '^2[selftest] data path OK (first run)^7' or '^2[selftest] data path OK and persistent^7') else print('^1[selftest] PROBLEMS FOUND^7') end end) RegisterCommand('selftest_reset', function() CreateThread(function() DB.Update('DELETE FROM accounts WHERE username = ?', { MARKER }) -- characters cascade print('^3[selftest]^7 marker account removed') end) end, true)