Test Users
A test user is a disposable account on one Environment, flagged is_test in the auth module's own table: it is excluded from your MAU count and your bill, its access token carries test: true, and it signs in through exactly the rails a real account uses. Test users live on the Environment, never in your repository — you mint them with palbase test-user, from Studio, or through the Environment's own Management API, and a fresh Environment comes up with none until something creates one. A named template stored on that same Environment is what gives a minted account its data: the rows it already owns the first time you sign in as it.
Quick example
palbase test-user create --template demo
# ▸ todoapp/main
# ✓ created 1 test user(s) from template "demo"
# id: usr_2f8c9a41
# email: test-9f3ab2c1@test.invalid
# password: 0f2c7b91a4e3d508
# token: eyJhbGciOiJFUzI1NiIsImtpZCI6…
# rows: 2 lists, 3 todos
# (creds shown once — store them now)
The account exists, it already owns the rows the demo template declares, and the access token is ready to send as a Bearer. With no --template you get the same thing without the rows — a plain throwaway login.
Warning: the password is minted by the Environment and printed once. Only its hash is stored, so nothing can show it to you again; re-running the command mints a different user rather than recovering the old one. The access token is minted with it, so a script that captures
--jsonnever needs to log in at all.
Where test users live
There is one door, and the CLI, Studio and the deploy all go through it — the Environment's own Management API at https://<ref>.palbase.studio/v1/management/*:
| Route | What it does |
|---|---|
POST /v1/management/test-users | mint — plain, from a template, or with credentials you supply |
GET /v1/management/test-users | list this Environment's is_test accounts |
GET /v1/management/test-users/templates | the templates this Environment holds |
PUT /v1/management/test-users/templates | replace the whole stored template set |
POST /v1/management/test-users/clone | copy one test user's data tree onto a new account |
PUT /v1/management/test-users/phone | attach a phone number and a fixed code to a test user |
DELETE /v1/management/test-users/{id} | purge one |
Each of them needs the Environment's own management credential — a Bearer token carrying the management claim, or the Environment's service-role key on the apikey header. See the two API keys and The CLI.
Every Environment verifies tokens against its own auth module, so a test user minted on one Environment does not exist on any other and its token is refused everywhere else.
The CLI surface
palbase test-user create # one plain throwaway login
palbase test-user create --count 5 # five of them
palbase test-user create --template demo # one instance of a stored template
palbase test-user templates # the templates this Environment holds
palbase test-user list # this Environment's test users
palbase test-user clone usr_2f8c9a41 # copy a user's whole data tree
palbase test-user delete usr_2f8c9a41 # purge one
| Command | Flags | Prints |
|---|---|---|
create | --count (default 1), --template, --json | id, email, password, token and rows: per user |
list | --json | a table of ID EMAIL VERIFIED; No test users in this project. when empty |
templates | --json | a table of NAME FIXTURE EMAIL SEEDS |
clone <user-id> | --email, --password, --set, --json | ✓ cloned <id>, the new credentials, and rows: |
delete <user-id> | — | ✓ deleted <id> |
--count applies to --template too: five instances of one template is an ordinary thing to want, and each gets its own copy of the declared rows.
Note: these commands act on the environment this checkout resolves to — one environment of the project
palbase/project.jsonnames. To mint on another environment of the same project, pass--env <name>for one command or select it withpalbase env use <name>; to work against another project, link a checkout to it. See Which environment a command acts on.
Templates: the data a minted user arrives with
A template is a named recipe stored on the Environment. It holds the seed — the tree of rows a freshly minted account should already own — and, optionally, a fixed email, password, phone and code.
palbase test-user templates shows what an Environment holds:
NAME FIXTURE EMAIL SEEDS
demo demo@test.local lists, todos
heavy_user - lists, todos
FIXTURE EMAIL is - when the template carries no credentials. The stored definition itself is not published by that route — a declaration can carry a password, and a list is not a credential store.
A template instance always gets generated credentials. create --template demo mints an account with the demo template's data and its own login, even when the template declares an email and a password: two accounts cannot share one address. The declared credentials matter only when something creates that one fixture account directly.
Writing a template
Nothing in the CLI and nothing in Studio writes a template. The only way to store one is to PUT the whole set to the Environment:
curl -X PUT "https://k3xq81w4m.palbase.studio/v1/management/test-users/templates" \
-H "apikey: pb_project_s…" \
-H "content-type: application/json" \
-d '{
"templates": {
"demo": {
"email": "demo@test.local",
"password": "demo-password-1234",
"seed": {
"lists": [
{ "title": "Groceries", "todos": [{ "title": "Milk" }, { "title": "Eggs", "done": true }] }
]
}
},
"heavy_user": {
"seed": { "lists": [{ "title": "L", "todos": [{ "title": "t" }] }] }
}
}
}'
The response is {"stored": 2}.
Warning: the
PUTreplaces the stored set rather than merging into it. A name you leave out of the body stops being offered. That is the intended semantics — dropping a recipe removes nothing anybody owns — but it means a partial body silently deletes the rest.
Each entry accepts email, password, phone, otp and seed, and all of them are optional. email and password are meaningful together or not at all; phone is meaningless without otp.
Nothing reads config/test-users.ts
Warning:
defineTestUsersandtestUserare still exported from@palbase/backendand still validate their input the moment you author it, but nothing reads the file. Neither bundler evaluatesconfig/, no deploy registers a template from it, no deploy creates a fixture account from it, andpalbase pushnever sees it. Aconfig/test-users.tsin your tree today is an authority that authorises nothing — delete it, and store your templates on the Environment instead. Settings, secrets and fixture templates have one door: the management API. See Stack Settings.
If an older checkout still carries a .palbase/config.json, delete it. That file is completely dead: nothing writes it and no production line reads it any more — the last reader went with the config/ retirement on 2026-08-29, and palbase push learns which platforms a checkout has from the committed slot files under palbase/environments/<env>/ instead, precisely because a per-machine, git-ignored file is absent in a fresh clone.
The seed tree
A seed is plain JSON: table name to rows. Two things are deliberately not in it — the column saying who owns a row, and the column linking a child to its parent — because the Environment fills both:
{
"lists": [
{ "title": "Groceries", "todos": [{ "title": "Milk" }, { "title": "Eggs", "done": true }] },
{ "title": "Work", "todos": [{ "title": "Ship it" }] }
]
}
The rules are enforced on the Environment, against the schema that is actually applied there, at the moment a user is minted:
- A top-level table must be user-rooted — it must have a column declared
ownedByUser(). A top-level row in a table with no owner column would belong to nobody, and nesting exists precisely so a child reaches its owner through its parent. AuserRef({ onDelete })column does not count: it points at a user without owning the row. - A key inside a row is either a column of that table or the name of a table that foreign-keys it. A key that is neither is refused by name, and the message lists the table's real columns.
- A key naming a nested table must hold an array of rows.
- The owner column and the child's foreign key are set for you. Writing them yourself is not supported — the values come from the account that was just minted and the parent row that was just written.
- One transaction per user, and the whole batch unwinds. If any row in any user's tree fails, every account the request minted is deleted again. A half-seeded fixture is worse than none, because the half looks like it worked.
The schema behind the tree is db/public.ts — see Schema. The owner column is written with one verb, and the text type the usr_… id needs is implied by it:
import { defineTable, ownedByUser, text, uuid } from "@palbase/backend";
const notes = defineTable("notes", {
columns: {
id: uuid().primaryKey().defaultRandom(),
user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE onto auth.users(id)
body: text().notNull(),
},
});
If an Environment has no application tables at all, a seed is refused with this project has no application tables, so the declared rows describe a schema that is not applied here — push your schema first.
Fixtures with credentials you choose
A fixture is a test user whose email and password you pick, so you can sign into your app as the same account every day. Neither palbase test-user create nor Studio's New test user dialog can create one: both mint generated credentials. The route can:
curl -X POST "https://k3xq81w4m.palbase.studio/v1/management/test-users" \
-H "apikey: pb_project_s…" \
-H "content-type: application/json" \
-d '{
"users": [
{ "email": "demo@test.local", "password": "demo-password-1234",
"seed": { "lists": [{ "title": "Groceries", "todos": [{ "title": "Milk" }] }] } }
],
"with_tokens": true
}'
email and password must be given together: half a credential pair would silently degrade a fixture into a random account nobody can sign in as, and the request is refused with each entry of users must carry both email and password. Creating a fixture whose address already exists answers 409 — which is what makes the call safe to repeat.
Warning: a fixture password is a real password on a real Environment. Anything that can read where you keep it can sign into that Environment as that account. That is the reason the old "declare it in git and the deploy creates it" model was removed rather than repaired.
Signing in over the phone rail
A test user can carry a phone number and a fixed code, and the pair is honoured only for is_test rows — checked twice, once when it is written and again on every login, so a real user can never inherit a code you wrote down.
curl -X PUT "https://k3xq81w4m.palbase.studio/v1/management/test-users/phone" \
-H "apikey: pb_project_s…" \
-H "content-type: application/json" \
-d '{ "email": "demo@test.local", "phone": "+905550001122", "otp": "424242" }'
All three fields are required, the call is idempotent, and it answers 404 test_user_not_found for an address that is not a test user on this Environment.
Once attached, requesting a code for that number sends no SMS at all — there is nothing that could receive one at a fabricated number, and a send would only cost money — and the verify step accepts exactly the code you declared. A wrong code is still refused, so the fixture is not a bypass. That makes a phone login testable offline and for free.
Cloning
Cloning copies a test user's whole data tree onto a newly minted account — the way to get a second realistic user without seeding from scratch:
palbase test-user clone usr_2f8c9a41 \
--email second@test.local --password second-password-1 \
--set profiles.display_name="Second User"
- The copy gets its own primary keys, and the new account owns them.
- Foreign keys inside the copied tree are remapped to the copies. A reference pointing outside it — a shared lookup row, say — is carried over unchanged, because it is not the user's row to duplicate.
--set table.column=value(repeatable) changes that column on every copied row of a table.nullbecomes JSON null, anything that parses as JSON arrives typed, and anything else stays a string. Column names are checked by the Environment against the live schema, not by the CLI.--emailand--passwordgo together or not at all; omit both and the Environment generates them.
The source must itself be a test user: cloning a real account would put a copy of real personal data into a throwaway one.
From Studio
Test Data → Test User Canvas, on an Environment, gives you the same operations in a browser:
- a picker over the Environment's test users,
- New test user — a template (or none) and a count,
- Clone — with the same optional email, password and per-table column overrides,
- Delete — the account and everything hanging off it,
- and a per-table browser for the selected user's rows, editable in place.
Credentials appear once, with copy buttons, exactly as they do in the CLI.
What a deploy does with them
A deploy does not create fixtures and does not register templates. What it does do is mint the identities your tests sign in as, from the templates the Environment already holds:
- It reads the Environment's template list.
- For each template it mints one account with generated credentials, its own copy of the seed, and an access token issued up front — which is why
signInAscosts no request and a suite that switches users never trips the login rate limiter. - It hands the suite that map as
PALBASE_TEST_IDENTITIES, keyed by template name. - It purges those accounts when the run ends, pass or fail.
An Environment with no templates mints nothing, and that is not a failure — a deploy is not refused over a feature the project does not use. The purge is best-effort and logged: an account that outlives its run is a nuisance, not a reason to report a working release as broken.
This is why a committed fixture password is not needed for tests at all. See Testing.
Limits and lifecycle
- They persist until something deletes them. There is no TTL. The exception is a deploy's own run identities, which are minted and retired inside one deploy.
- A per-Environment cap applies to how many
is_testaccounts can exist at once —auth.test_users_max, 50 by default. A batch that would cross it is refused whole, before any account is created, with429 test_user_cap_exceeded. Purge some and retry. - One request may mint at most 1000 accounts; a larger
countis a400. - A generated login is
test-<8 hex>@test.invalidwith a 16-character hex password. The.invalidTLD guarantees the address can never receive mail. - They are excluded from MAU and billing, and their tokens carry
test: trueif you want to branch on it server-side. - Deleting one cascades: the account and every row that hangs off it go together.
Related
- Testing — the run identities a deploy mints from these templates
- Schema —
db/public.ts, and theownedByUser()column a seed tree roots on - Row-Level Security — what a signed-in test user can actually see
- CLI: Test Users — the command reference, flags and
--jsonshapes - Stack Settings — why
config/*.tsno longer reaches a stack - The CLI — the credential the routes above need