Part 1 final build: Los Santos RP written on camera by the agent
rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db), county-records NUI: residency-file auth, identity-record character intake, survey-grid spawn, procedural night-city loading screen. Secrets replaced with .example files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
Reference in New Issue
Block a user