Skip to main content

Test Data & Environment Appendix — SAPOT Mobile App

Companion to test-cases.md.
Last updated: 2026-06-25


Contents


A. Environment Prerequisites

All automated and manual tests target the staging server. Never run state-mutation procedures against production.

ComponentVersion / TargetConfig source
Python FastAPI serverserver/ — run via runserver.shserver/.env
MySQL 8sapot_db database, local or stagingDB_URL in .env
Redisredis://localhost:6379REDIS_URL in .env — server falls back to MySQL if absent
Expo appmobile-app/sapot-mobile-app/config/runtime.ts
Server URL (staging)https://sapot.onlineEAS channel preview / production
Server URL (local dev)http://<DEV_HOST>:8000DEV_HOST in config/runtime.ts

Required server env vars for the test environment

Add to server/.env on the staging or local test server — not production:

# Enables /testing/* routes (already included in main.py; must be removed before production)
SAPOT_TEST_MODE=true

# Deterministic OTP override — see Section D (leave unset on production)
# SAPOT_TEST_OTP_CODE=123456

JWT_SECRET_KEY=<64-char hex — must match any tokens already minted in this environment>

B. Seeded Account Catalog

These are the canonical test identities. Create them once per environment using the script in Section B.3. All passwords satisfy the server's complexity rule: ≥8 chars, ≥1 digit, ≥1 lowercase, ≥1 uppercase.

B.1 Account table

HandleUsernamePasswordRoleSpecial statePrimary TCs
qa_baselineqa_baselineBaseline1regular usernoneMost auth / chat / call tests
qa_baseline_bqa_baseline_bBaseline1regular usernone2-device peer — TC-280–TC-289
qa_rescuerqa_rescuerRescuer1!rescuerrescuer row presentTC-170, TC-171, TC-201
qa_adminqa_adminAdmin1!xxadminadmin row presentAll /admin/* calls
qa_bannedqa_bannedBanned1!xregular userbanned_user.until = 2099-01-01TC-014
qa_phoneqa_phonePhone1!xxregular userphone_number = +639171110001, phone verifiedTC-054, TC-055, TC-089
qa_emailqa_emailEmail1!xxregular useremail = qa_email@sapot.testTC-051, TC-053

Guests are ephemeral LAN identities created inside the app (Getting Started → LAN mode). No server account or seeding is required.

B.2 Account states that require additional setup after registration

AccountWhat to do after POST /auth/Why
qa_rescuerPOST /testing/test-make-rescuer?username=qa_rescuerInserts row into rescuer table
qa_adminPOST /testing/test-make-admin?username=qa_adminInserts row into admin table
qa_bannedPOST /admin/ban/user?user_id=<uuid>&duration_in_days=26645 (auth as qa_admin)Creates banned_user row with until ~73 years from now
qa_phoneInsert into phone_verified table (see SQL below)Phone OTP flow not automatable without a real SMS sink
qa_lockedRun lockout SQL (see Section C) before each TC-013 run; reset afterlogin_attempt row with attempt_count=5, locked_until in future

Phone verification SQL (run on staging DB only):

INSERT INTO phone_verified (id, user_id, phone_number, verified_at)
SELECT UUID(), id, phone_number, NOW()
FROM user WHERE username = 'qa_phone'
ON DUPLICATE KEY UPDATE verified_at = NOW();

B.3 Seed script

Run once per environment. Requires curl and jq. Replace BASE_URL as needed.

#!/usr/bin/env bash
# seed-test-accounts.sh
set -euo pipefail
BASE_URL="${SAPOT_TEST_BASE_URL:-http://localhost:8000}"

register() {
local username=$1 password=$2 first=$3 last=$4 extra="${5:-}"
curl -sf -X POST "$BASE_URL/auth/" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$username\",\"password\":\"$password\",\
\"first_name\":\"$first\",\"last_name\":\"$last\",\"terms_accepted\":true$extra}" \
| jq -r '.access_token // empty'
}

# Plain users
register qa_baseline Baseline1 QA Baseline
register qa_baseline_b Baseline1 QA BaselineB

# Rescuer
register qa_rescuer "Rescuer1!" QA Rescuer
curl -sf -X POST "$BASE_URL/testing/test-make-rescuer?username=qa_rescuer" > /dev/null

# Admin (seeded first so it can be used to ban others)
register qa_admin "Admin1!xx" QA Admin
curl -sf -X POST "$BASE_URL/testing/test-make-admin?username=qa_admin" > /dev/null

ADMIN_TOKEN=$(curl -sf -X POST "$BASE_URL/auth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=qa_admin&password=Admin1!xx" | jq -r '.access_token')

# Banned user
register qa_banned "Banned1!x" QA Banned
BANNED_ID=$(curl -sf "$BASE_URL/auth/exists?identifier=qa_banned" | jq -r '.user_id')
curl -sf -X POST "$BASE_URL/admin/ban/user?user_id=$BANNED_ID&duration_in_days=26645" \
-H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null

# Phone user (phone verification applied via SQL after this step)
register qa_phone "Phone1!xx" QA Phone ',\"phone_number\":\"+639171110001\"'

# Email user
register qa_email "Email1!xx" QA Email ',\"email\":\"qa_email@sapot.test\"'

echo "Accounts created. Run phone_verified INSERT for qa_phone, then verify with:"
echo " curl -sf $BASE_URL/auth/exists?identifier=qa_baseline | jq"

C. State-Control API Reference

C.1 Endpoints that exist today

All /admin/* routes require Authorization: Bearer <qa_admin access token>.

# Convenience — store admin token in a shell variable
ADMIN_TOKEN=$(curl -sf -X POST "$BASE_URL/auth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=qa_admin&password=Admin1!xx" | jq -r '.access_token')
Precondition to produceMethodPathKey params
Account is adminPOST/testing/test-make-admin?username=<x> (no auth required)
Account is rescuerPOST/testing/test-make-rescuer?username=<x> (no auth required)
Account is bannedPOST/admin/ban/user?user_id=<uuid>&duration_in_days=<n>
Account is unbannedPOST/admin/unban/user?user_id=<uuid>
New user via adminPOST/admin/create/userJSON UserCreate body
User hard-deletedPOST/admin/delete/user?user_id=<uuid>

Security note: /testing/test-make-admin and /testing/test-make-rescuer carry no auth guard — this is the open bug documented in TC-246. These routes must be removed or auth-gated before any production deployment.

C.2 States that require direct DB manipulation

The API does not expose endpoints for lockout, aged-session, or OTP expiry. Use these SQL statements on staging only.

Locked-out account (TC-013) — 5+ failed attempts, active lockout

-- Produces a tier-2 (6-hour) lockout so it survives the duration of a test run
INSERT INTO login_attempt
(id, user_id, device_fingerprint, device_type, attempt_count, lockout_count, locked_until, last_attempt_at)
SELECT UUID(), id, '127.0.0.1', 'anonymous', 5, 1,
DATE_ADD(NOW(), INTERVAL 6 HOUR),
NOW()
FROM user WHERE username = 'qa_baseline'
ON DUPLICATE KEY UPDATE
attempt_count = 5,
lockout_count = 1,
locked_until = DATE_ADD(NOW(), INTERVAL 6 HOUR),
last_attempt_at = NOW();

Reset lockout after the test:

UPDATE login_attempt la
JOIN user u ON la.user_id = u.id
SET la.attempt_count = 0, la.locked_until = NULL
WHERE u.username = 'qa_baseline';

Aged session / expired refresh token (TC-020) — session older than 60 days

Refresh tokens are stateless JWTs; there is no token table to back-date. The workaround is to mint a token whose exp is already past the 60-day window and present it to /auth/refresh:

# python produce_aged_token.py (run on the staging server or locally with the same JWT_SECRET_KEY)
import os, jwt, datetime, uuid

secret = os.environ["JWT_SECRET_KEY"]
# exp one second in the past — already expired
old_exp = datetime.datetime.utcnow() - datetime.timedelta(seconds=1)
payload = {
"sub": "<qa_baseline user UUID from DB>",
"jti": str(uuid.uuid4()),
"type": "refresh",
"exp": old_exp,
}
token = jwt.encode(payload, secret, algorithm="HS256")
print(token)

Pass the output as {"refresh_token": "<token>"} to POST /auth/refresh. The server returns 401 Token has expired — the expected result for TC-020.

Expired email OTP (TC-053)

After triggering POST /auth/forgot-password/email for qa_email:

UPDATE passwordresetcode
SET expires_at = DATE_SUB(NOW(), INTERVAL 11 MINUTE)
WHERE user_id = (SELECT id FROM user WHERE username = 'qa_email')
ORDER BY created_at DESC
LIMIT 1;

SMS OTP with 3 wrong attempts (TC-055)

After triggering POST /auth/forgot-password/otp/send for qa_phone:

UPDATE phone_password_reset_code
SET attempts = 3
WHERE user_id = (SELECT id FROM user WHERE username = 'qa_phone')
ORDER BY created_at DESC
LIMIT 1;

The next /otp/verify call (any code) returns 429 Too Many Attempts — the expected result for TC-055.

Expired email-recovery token (TC-061)

After triggering POST /auth/forgot-password/email-recovery/send for qa_email:

UPDATE email_recovery_token
SET expires_at = DATE_SUB(NOW(), INTERVAL 31 MINUTE)
WHERE user_id = (SELECT id FROM user WHERE username = 'qa_email')
AND used = FALSE
ORDER BY created_at DESC
LIMIT 1;

Recovery-key cooldown (TC-073)

After qa_baseline has generated a recovery key at least once:

-- Simulate "just generated 1 day ago" — well within the 30-day cooldown
UPDATE recoverykey
SET updated_at = DATE_SUB(NOW(), INTERVAL 1 DAY)
WHERE user_id = (SELECT id FROM user WHERE username = 'qa_baseline');

D. Deterministic OTP Test Mode

D.1 Current state

generate_reset_code() in server/app/api/forgot_password.py uses secrets.randbelow(900000) + 100000. The OTP code is:

  • Email path (passwordresetcode table): stored as plaintext — readable via SQL (workaround below).
  • SMS new path (phone_password_reset_code table): stored as a SHA-256 hash — the plaintext is never persisted and cannot be recovered from the DB.

This makes TC-051 (email OTP) partially workable via SQL and TC-054 / TC-055 (SMS OTP) unautomatable until the override is implemented.

D.2 Proposed server change

Add an environment-variable override to generate_reset_code() in server/app/api/forgot_password.py:

# server/app/api/forgot_password.py — proposed addition
import os

def generate_reset_code() -> str:
override = os.getenv("SAPOT_TEST_OTP_CODE", "")
if override and override.isdigit() and len(override) == 6:
return override # deterministic only when explicitly set
return str(secrets.randbelow(900000) + 100000)

Enable on the test server:

# server/.env (staging / local only — never set in production)
SAPOT_TEST_OTP_CODE=123456

With this in place, all OTP tests can submit 123456 as the code without reading the database or waiting for real email/SMS delivery.

Guard against accidental production use: the function only activates the override when the env var is a non-empty 6-digit string. Omit the var (or set it to an empty string) in production .env.

D.3 Workaround until the change lands

Email OTP — read the plaintext code from the DB after triggering the send:

SELECT code, expires_at
FROM passwordresetcode
WHERE user_id = (SELECT id FROM user WHERE username = 'qa_email')
ORDER BY created_at DESC
LIMIT 1;

SMS OTP — no workaround is possible (SHA-256 hash only). Until SAPOT_TEST_OTP_CODE is implemented:

  • TC-054 must remain MANUAL.
  • TC-055 uses the DB manipulation from Section C.2 (set attempts = 3) rather than submitting wrong codes.

E. Reusable Login Fixture (Jest / RNTL)

These helpers follow the factory pattern established in test/builders/factory.builder.ts.

E.1 Auth state factory — test/fixtures/auth-state.fixture.ts

import { createFactory } from "../builders/factory.builder";

export interface AuthUserState {
userId: string;
username: string;
firstName: string;
lastName: string;
accessToken: string;
refreshToken: string;
isGuest: false;
}

export interface AuthGuestState {
userId: string;
username: string;
firstName: string;
lastName: string;
isGuest: true;
}

const STUB_ACCESS_TOKEN = "stub-access-token";
const STUB_REFRESH_TOKEN = "stub-refresh-token";

export const createAuthUserState = createFactory<AuthUserState>(() => ({
userId: "test-user-id",
username: "qa_baseline",
firstName: "QA",
lastName: "Baseline",
accessToken: STUB_ACCESS_TOKEN,
refreshToken: STUB_REFRESH_TOKEN,
isGuest: false,
}));

export const createAuthGuestState = createFactory<AuthGuestState>(() => ({
userId: "guest-test-id",
username: "qa_guest",
firstName: "Guest",
lastName: "User",
isGuest: true,
}));

E.2 UserStore mock helper — test/fixtures/user-store.fixture.ts

import { createAuthUserState, AuthUserState } from "./auth-state.fixture";

export function createMockUserStore(overrides?: Partial<AuthUserState>) {
const state = createAuthUserState(overrides);
return {
user: {
id: state.userId,
username: state.username,
firstName: state.firstName,
lastName: state.lastName,
},
isGuest: false as const,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
setUser: jest.fn(),
clearUser: jest.fn(),
};
}

export function createMockGuestUserStore() {
return {
user: {
id: "guest-id",
username: "guest-user",
firstName: "Guest",
lastName: "User",
},
isGuest: true as const,
accessToken: null,
refreshToken: null,
setUser: jest.fn(),
clearUser: jest.fn(),
};
}

E.3 Usage in a Jest / RNTL test

import { createMockUserStore } from "@/test/fixtures/user-store.fixture";
import { createConnectionServiceDependencyMocks } from "@/test/mocks/service.mock-builders";

describe("ChatService — authenticated user", () => {
it("sends a message when WebRTC is connected", async () => {
// Arrange
const userStore = createMockUserStore({ username: "qa_baseline" });
const deps = createConnectionServiceDependencyMocks();
// wire userStore into your service under test ...

// Act + Assert ...
});
});

describe("ChatService — guest user", () => {
it("cannot initiate a server-relay send", async () => {
const userStore = createMockGuestUserStore();
// ...
});
});

E.4 API-level login helper (Pytest integration tests)

# server/tests/fixtures/auth.py
import os, requests

BASE_URL = os.getenv("SAPOT_TEST_BASE_URL", "http://localhost:8000")


def login(username: str, password: str) -> dict:
"""Returns the full token response dict."""
resp = requests.post(
f"{BASE_URL}/auth/token",
data={"username": username, "password": password},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
resp.raise_for_status()
return resp.json()


def login_as_baseline() -> dict:
return login("qa_baseline", "Baseline1")


def login_as_admin() -> dict:
return login("qa_admin", "Admin1!xx")


def admin_bearer() -> str:
return login_as_admin()["access_token"]
# Example Pytest test using the fixture
from fixtures.auth import login_as_baseline

def test_refresh_rotates_token(client):
tokens = login_as_baseline()
resp = client.post("/auth/refresh", json={"refresh_token": tokens["refresh_token"]})
assert resp.status_code == 200
assert resp.json()["access_token"] != tokens["access_token"]

F. Teardown & Reset

F.1 Reset lockout for all QA accounts (between runs)

UPDATE login_attempt la
JOIN user u ON la.user_id = u.id
SET la.attempt_count = 0, la.locked_until = NULL, la.lockout_count = 0
WHERE u.username LIKE 'qa\_%';

F.2 Purge all QA OTP / token records

DELETE prc FROM passwordresetcode prc
JOIN user u ON prc.user_id = u.id WHERE u.username LIKE 'qa\_%';

DELETE pprc FROM phone_password_reset_code pprc
JOIN user u ON pprc.user_id = u.id WHERE u.username LIKE 'qa\_%';

DELETE ert FROM email_recovery_token ert
JOIN user u ON ert.user_id = u.id WHERE u.username LIKE 'qa\_%';

DELETE prt FROM passwordresettoken prt
JOIN user u ON prt.user_id = u.id WHERE u.username LIKE 'qa\_%';

F.3 Re-apply ban on qa_banned (after unban between runs)

ADMIN_TOKEN=$(curl -sf -X POST "$BASE_URL/auth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=qa_admin&password=Admin1!xx" | jq -r '.access_token')
BANNED_ID=$(curl -sf "$BASE_URL/auth/exists?identifier=qa_banned" | jq -r '.user_id')
curl -sf -X POST "$BASE_URL/admin/unban/user?user_id=$BANNED_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null
curl -sf -X POST "$BASE_URL/admin/ban/user?user_id=$BANNED_ID&duration_in_days=26645" \
-H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null

F.4 Full teardown and re-seed (nightly CI)

#!/usr/bin/env bash
# teardown-and-reseed.sh
set -euo pipefail
BASE_URL="${SAPOT_TEST_BASE_URL:-http://localhost:8000}"

ADMIN_TOKEN=$(curl -sf -X POST "$BASE_URL/auth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=qa_admin&password=Admin1!xx" | jq -r '.access_token')

for USERNAME in qa_baseline qa_baseline_b qa_rescuer qa_banned qa_phone qa_email; do
USER_ID=$(curl -sf "$BASE_URL/auth/exists?identifier=$USERNAME" | jq -r '.user_id // empty')
if [ -n "$USER_ID" ]; then
curl -sf -X POST "$BASE_URL/admin/delete/user?user_id=$USER_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null
echo "Deleted $USERNAME"
fi
done

bash seed-test-accounts.sh

qa_admin is excluded from the teardown loop — it is bootstrapped once per environment and retained as the persistent admin identity for all /admin/* calls.