package oltp
import (
"path/filepath"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/xfer"
)
// mint is MintToken with the test's defaults: every repository, no
// ceiling, the default life.
func mint(t *testing.T, db *DB, account string, scopes ...string) (string, Token) {
t.Helper()
value, tok, err := db.MintToken(account, TokenSpec{Name: "t", Scopes: scopes})
if err != nil {
t.Fatal(err)
}
return value, tok
}
// The value carries the prefix and the row's id, so a scanner can
// recognise one and the forge finds its row without trying every token.
func TestATokenValueNamesItsRow(t *testing.T) {
db := tokenDB(t, "sandbox")
writerWithToken(t, db, "jan", "sandbox")
value, tok := mint(t, db, "jan", ScopeAPI)
if !strings.HasPrefix(value, "qat_"+tok.ID+"_") {
t.Errorf("FAIL: value %q does not carry prefix and id %q", value, tok.ID)
}
if !LooksLikeToken(value) {
t.Errorf("FAIL: a minted value is not recognised as a token")
}
if LooksLikeToken("correct horse battery staple") {
t.Errorf("FAIL: a password is taken for a token")
}
}
// api and web tokens are kept as a hash; a sync token is sealed,
// because the login card makes the server derive the secret.
func TestATokenIsHashedUnlessItSyncs(t *testing.T) {
db := tokenDB(t, "sandbox")
writerWithToken(t, db, "jan", "sandbox")
for _, tc := range []struct {
scope string
sealed int
}{{ScopeAPI, 0}, {ScopeWeb, 0}, {ScopeSync, 1}} {
value, tok := mint(t, db, "jan", tc.scope)
var verifier string
var sealed int
if err := db.db.QueryRow(`SELECT verifier, sealed FROM token WHERE id = ?`, tok.ID).Scan(&verifier, &sealed); err != nil {
t.Fatal(err)
}
if sealed != tc.sealed {
t.Errorf("FAIL: %s token sealed=%d, want %d", tc.scope, sealed, tc.sealed)
}
if strings.Contains(verifier, value) {
t.Errorf("FAIL: %s token stored in the clear", tc.scope)
}
if tc.sealed == 0 && verifier != hashToken(value) {
t.Errorf("FAIL: %s token verifier is not the value's sha256", tc.scope)
}
}
}
// A token opens only the doors its scope names.
func TestATokenWorksOnlyAtItsScopesDoors(t *testing.T) {
db := tokenDB(t, "sandbox")
writerWithToken(t, db, "jan", "sandbox")
apiTok, _ := mint(t, db, "jan", ScopeAPI)
syncTok, _ := mint(t, db, "jan", ScopeSync)
both, _ := mint(t, db, "jan", ScopeAPI, ScopeWeb)
for _, tc := range []struct {
value, scope string
want bool
}{
{apiTok, ScopeAPI, true},
{apiTok, ScopeWeb, false},
{apiTok, ScopeSync, false},
{syncTok, ScopeSync, true},
{syncTok, ScopeAPI, false},
{syncTok, ScopeWeb, false},
{both, ScopeAPI, true},
{both, ScopeWeb, true},
{both, ScopeSync, false},
} {
if _, ok := db.ResolveToken("jan", tc.value, tc.scope); ok != tc.want {
t.Errorf("FAIL: token at the %s door: ok=%v, want %v", tc.scope, ok, tc.want)
}
}
// Another account's name with a good token is a miss.
if err := db.AddAccount("kim", "pw-kim-123", false); err != nil {
t.Fatal(err)
}
if _, ok := db.ResolveToken("kim", apiTok, ScopeAPI); ok {
t.Errorf("FAIL: jan's token resolved for kim")
}
// A disabled account's tokens are refused with its password.
if err := db.SetDisabled("jan", true); err != nil {
t.Fatal(err)
}
if _, ok := db.ResolveToken("jan", apiTok, ScopeAPI); ok {
t.Errorf("FAIL: a disabled account's token resolved")
}
if err := db.SetDisabled("jan", false); err != nil {
t.Fatal(err)
}
// A wrong secret behind a right id is a miss.
forged := apiTok[:len(apiTok)-1] + "0"
if forged == apiTok {
forged = apiTok[:len(apiTok)-1] + "1"
}
if _, ok := db.ResolveToken("jan", forged, ScopeAPI); ok {
t.Errorf("FAIL: a forged secret resolved")
}
}
// Expiry and revocation take effect on the next request, at every door
// including the login card's secret.
func TestExpiryAndRevokeAreImmediate(t *testing.T) {
db := tokenDB(t, "sandbox")
if err := db.AddAccount("jan", "pw-jan-123", false); err != nil {
t.Fatal(err)
}
if err := db.Grant("jan", "sandbox", Write); err != nil {
t.Fatal(err)
}
a, ta := mint(t, db, "jan", ScopeSync, ScopeAPI)
b, tb := mint(t, db, "jan", ScopeSync, ScopeAPI)
if len(db.Secrets("sandbox", "jan")) != 2 {
t.Fatalf("FAIL: two live sync tokens give %d credentials", len(db.Secrets("sandbox", "jan")))
}
if err := db.RevokeToken("jan", ta.ID); err != nil {
t.Fatal(err)
}
if _, ok := db.ResolveToken("jan", a, ScopeAPI); ok {
t.Errorf("FAIL: a revoked token still resolves")
}
if got := db.Secrets("sandbox", "jan"); len(got) != 1 || got[0].Token != tb.ID {
t.Errorf("FAIL: after revoking one, credentials = %+v", got)
}
past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
if _, err := db.w.Exec(`UPDATE token SET expires = ? WHERE id = ?`, past, tb.ID); err != nil {
t.Fatal(err)
}
if _, ok := db.ResolveToken("jan", b, ScopeSync); ok {
t.Errorf("FAIL: an expired token still resolves")
}
if got := db.Secrets("sandbox", "jan"); len(got) != 0 {
t.Errorf("FAIL: an expired token still signs: %+v", got)
}
// Both rows stay, for the list.
list, err := db.Tokens("jan")
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Errorf("FAIL: list has %d rows after revoke and expiry, want 2", len(list))
}
// Revoking is the owner's act alone.
if err := db.AddAccount("kim", "pw-kim-123", false); err != nil {
t.Fatal(err)
}
_, tk := mint(t, db, "kim", ScopeAPI)
if err := db.RevokeToken("jan", tk.ID); err == nil {
t.Errorf("FAIL: jan revoked kim's token")
}
}
// The ceiling caps the letters; the public set survives it; s never
// flows to a token.
func TestTheCeilingCapsLetters(t *testing.T) {
db := tokenDB(t, "sandbox")
if err := db.AddAccount("jan", "pw-jan-123", true); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("jan", "sandbox", "aiy"); err != nil {
t.Fatal(err)
}
pub := string(db.EffectiveCaps("", "sandbox"))
_, open := mint(t, db, "jan", ScopeAPI)
_, tok, err := db.MintToken("jan", TokenSpec{Name: "uv", Scopes: []string{ScopeAPI}, Ceiling: "y"})
if err != nil {
t.Fatal(err)
}
if c := db.TokenCaps(open, "sandbox"); !c.HasAny("a") || !c.HasAny("i") || !c.HasAny("y") {
t.Errorf("FAIL: no ceiling, caps %q lose the account's letters", c)
}
c := db.TokenCaps(tok, "sandbox")
if !c.HasAny("y") {
t.Errorf("FAIL: ceiling y dropped y: %q", c)
}
for _, l := range "ai" {
if c.HasAny(string(l)) {
t.Errorf("FAIL: ceiling y kept %c: %q", l, c)
}
}
for _, l := range pub {
if !c.HasAny(string(l)) {
t.Errorf("FAIL: the ceiling took the public letter %c: %q", l, c)
}
}
// A site admin's token holds no s, not even at the forge.
if db.TokenCaps(open, "").HasAny("s") {
t.Errorf("FAIL: a token carries s")
}
// A repository the token does not name is reached as the public.
_, narrow, err := db.MintToken("jan", TokenSpec{Name: "n", Scopes: []string{ScopeAPI}, Repos: []string{"elsewhere"}})
if err != nil {
t.Fatal(err)
}
if db.TokenCaps(narrow, "sandbox").HasAny("i") {
t.Errorf("FAIL: a token naming another repository holds i here")
}
}
// A y-only token signs for unversioned pushes and says it may not check
// in; an account holding neither i nor y gets no credential at all.
func TestAYOnlyTokenSignsForUnversionedOnly(t *testing.T) {
db := tokenDB(t, "sandbox")
if err := db.AddAccount("pub", "pw-pub-123", false); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("pub", "sandbox", "oy"); err != nil {
t.Fatal(err)
}
value, tok := mint(t, db, "pub", ScopeSync)
got := db.Secrets("sandbox", "pub")
if len(got) != 1 {
t.Fatalf("FAIL: a y holder's sync token gives %d credentials, want 1", len(got))
}
if got[0].CheckIn || !got[0].Publish {
t.Errorf("FAIL: y-only credential = %+v, want publish without check-in", got[0])
}
if got[0].Token != tok.ID || got[0].Secret != xfer.SharedSecret("CODE-sandbox", "pub", value) {
t.Errorf("FAIL: the credential is not the token's derived secret")
}
if err := db.SetCaps("pub", "sandbox", "o"); err != nil {
t.Fatal(err)
}
if got := db.Secrets("sandbox", "pub"); len(got) != 0 {
t.Errorf("FAIL: a reader's token signs a push: %+v", got)
}
}
// Minting a second push token keeps the first working: the hazard this
// table exists to end.
func TestMintingASecondPushTokenKeepsTheFirst(t *testing.T) {
db := tokenDB(t, "sandbox")
first := writerWithToken(t, db, "jan", "sandbox")
second, err := db.MintSyncToken("jan")
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
xfer.SharedSecret("CODE-sandbox", "jan", first): false,
xfer.SharedSecret("CODE-sandbox", "jan", second): false,
}
for _, c := range db.Secrets("sandbox", "jan") {
if _, ok := want[c.Secret]; ok {
want[c.Secret] = true
}
}
for _, seen := range want {
if !seen {
t.Errorf("FAIL: a minted push token no longer signs")
}
}
for _, v := range []string{first, second} {
if _, ok := db.ResolveToken("jan", v, ScopeSync); !ok {
t.Errorf("FAIL: a push token does not resolve at the sync door")
}
}
}
// The per-account cap bounds the xfer loop.
func TestTheTwentySixthTokenIsRefused(t *testing.T) {
db := tokenDB(t)
if err := db.AddAccount("jan", "pw-jan-123", false); err != nil {
t.Fatal(err)
}
for i := 0; i < TokensPerAccount; i++ {
mint(t, db, "jan", ScopeAPI)
}
if _, _, err := db.MintToken("jan", TokenSpec{Name: "one more", Scopes: []string{ScopeAPI}}); err == nil {
t.Errorf("FAIL: token %d was minted", TokensPerAccount+1)
}
}
// Expiry is required and bounded.
func TestExpiryIsBounded(t *testing.T) {
db := tokenDB(t)
if err := db.AddAccount("jan", "pw-jan-123", false); err != nil {
t.Fatal(err)
}
_, tok := mint(t, db, "jan", ScopeAPI)
exp, err := time.Parse(time.RFC3339, tok.Expires)
if err != nil {
t.Fatal(err)
}
if d := time.Until(exp); d < 89*24*time.Hour || d > 91*24*time.Hour {
t.Errorf("FAIL: default expiry is %v out, want 90 days", d)
}
if _, _, err := db.MintToken("jan", TokenSpec{Name: "x", Scopes: []string{ScopeAPI},
Expires: time.Now().Add(400 * 24 * time.Hour)}); err == nil {
t.Errorf("FAIL: a token expiring past a year was minted")
}
if _, _, err := db.MintToken("jan", TokenSpec{Name: "x", Scopes: []string{"push"}}); err == nil {
t.Errorf("FAIL: an unknown scope word was accepted")
}
}
// run- is reserved for run tokens' logins: no account, invite or team
// may take it.
func TestRunPrefixIsReserved(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("run-7", "pw-run-123", false); err == nil {
t.Errorf("FAIL: AddAccount took a run- name")
}
if _, err := db.InviteAccount("Run-8", "", false); err == nil {
t.Errorf("FAIL: InviteAccount took a run- name")
}
if err := db.AddTeam("run-9", "", "", ""); err == nil {
t.Errorf("FAIL: AddTeam took a run- name")
}
if err := db.AddAccount("runner", "pw-runner-1", false); err != nil {
t.Errorf("FAIL: a name merely starting with run was refused: %v", err)
}
// Names that predate the reservation are reported, never renamed.
if _, err := db.w.Exec(`INSERT INTO account (name, pwhash, admin, created) VALUES ('run-old', '', 0, '2026-01-01T00:00:00Z')`); err != nil {
t.Fatal(err)
}
if _, err := db.w.Exec(`INSERT INTO team (name, title, descr, created, created_by) VALUES ('run-team', '', '', '2026-01-01T00:00:00Z', '')`); err != nil {
t.Fatal(err)
}
got := strings.Join(db.ReservedRunNames(), ",")
if got != "run-old,run-team" {
t.Errorf("FAIL: reserved names reported %q", got)
}
}
// An existing sync_token row becomes a list row named push, sealed,
// sync-scoped, that still signs and still opens the Basic round with the
// value its holder already has; the old table goes.
func TestMigrationTurnsASyncTokenIntoAListRow(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "oltp.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := db.AddRepo("sandbox", filepath.Join(dir, "sandbox"), "", "public"); err != nil {
t.Fatal(err)
}
if err := db.AddAccount("jan", "pw-jan-123", false); err != nil {
t.Fatal(err)
}
if err := db.Grant("jan", "sandbox", Write); err != nil {
t.Fatal(err)
}
// The old shape, as a store from before this change holds it.
legacy := "ABCD-EFGH-JKMN-PQRS-TVWX-YZ01-2345-6789"
sealed, err := db.seal.seal(legacy)
if err != nil {
t.Fatal(err)
}
if _, err := db.w.Exec(`CREATE TABLE sync_token (user TEXT PRIMARY KEY, sealed TEXT NOT NULL, created TEXT NOT NULL)`); err != nil {
t.Fatal(err)
}
if _, err := db.w.Exec(`INSERT INTO sync_token VALUES ('jan', ?, '2026-03-01T00:00:00Z')`, sealed); err != nil {
t.Fatal(err)
}
db.Close()
db, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SyncProjectCode = func(repo string) (string, bool) { return "CODE-" + repo, true }
// The old rows stay, so an older build rolled back onto this
// database still finds its push credentials.
var n int
if err := db.db.QueryRow(`SELECT count(*) FROM sync_token WHERE user = 'jan' AND sealed = ?`, sealed).Scan(&n); err != nil || n != 1 {
t.Errorf("FAIL: the sync_token row is gone (n=%d, err=%v)", n, err)
}
// Opening again copies nothing twice.
db.Close()
db, err = Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
db.SyncProjectCode = func(repo string) (string, bool) { return "CODE-" + repo, true }
list, err := db.Tokens("jan")
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("FAIL: %d list rows after migration, want 1", len(list))
}
row := list[0]
if row.Name != "push" || !row.Sealed || strings.Join(row.Scopes, ",") != ScopeSync ||
len(row.Repos) != 0 || row.Ceiling != "" || row.Created != "2026-03-01T00:00:00Z" {
t.Errorf("FAIL: migrated row = %+v", row)
}
exp, _ := time.Parse(time.RFC3339, row.Expires)
if d := time.Until(exp); d < 364*24*time.Hour || d > 366*24*time.Hour {
t.Errorf("FAIL: migrated row expires %v out, want a year", d)
}
if _, ok := db.ResolveToken("jan", legacy, ScopeSync); !ok {
t.Errorf("FAIL: the holder's existing value no longer opens the Basic round")
}
got := db.Secrets("sandbox", "jan")
if len(got) != 1 || got[0].Secret != xfer.SharedSecret("CODE-sandbox", "jan", legacy) || !got[0].CheckIn {
t.Errorf("FAIL: the migrated token does not sign a push: %+v", got)
}
}
// Hand-issued sync_user rows are listed and still win at the front.
func TestSyncUserRowsAreListedAndStillWin(t *testing.T) {
db := tokenDB(t, "sandbox")
writerWithToken(t, db, "jan", "sandbox")
if err := db.SetSyncUser("sandbox", "jan", "issued-by-hand"); err != nil {
t.Fatal(err)
}
got := db.Secrets("sandbox", "jan")
if len(got) < 2 || got[0].Secret != "issued-by-hand" || got[0].Token != "" {
t.Errorf("FAIL: the hand-issued row is not first: %+v", got)
}
repos, err := db.LegacySyncUsers("jan")
if err != nil {
t.Fatal(err)
}
if strings.Join(repos, ",") != "sandbox" {
t.Errorf("FAIL: legacy rows listed as %q", repos)
}
if err := db.RevokeLegacySyncUser("jan", "sandbox"); err != nil {
t.Fatal(err)
}
if repos, _ := db.LegacySyncUsers("jan"); len(repos) != 0 {
t.Errorf("FAIL: a revoked legacy row is still listed")
}
}
// Expired rows never crowd out live ones: twenty-five expired, unrevoked
// sync tokens and a fresh one still sign, resolve and are reported, and
// revoking all reaches every unrevoked row.
func TestExpiredTokensDoNotHideLiveOnes(t *testing.T) {
db := tokenDB(t, "sandbox")
if err := db.AddAccount("jan", "pw-jan-123", false); err != nil {
t.Fatal(err)
}
if err := db.Grant("jan", "sandbox", Write); err != nil {
t.Fatal(err)
}
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
for i := 0; i < TokensPerAccount; i++ {
_, tok := mint(t, db, "jan", ScopeSync)
if _, err := db.w.Exec(`UPDATE token SET expires = ?, created = '2020-01-01T00:00:00Z' WHERE id = ?`, past, tok.ID); err != nil {
t.Fatal(err)
}
}
live, tok := mint(t, db, "jan", ScopeSync)
if got := db.Secrets("sandbox", "jan"); len(got) != 1 || got[0].Token != tok.ID {
t.Errorf("FAIL: the live token behind 25 expired ones does not sign: %+v", got)
}
if _, ok := db.ResolveToken("jan", live, ScopeSync); !ok {
t.Errorf("FAIL: the live token does not resolve")
}
if _, ok := db.SyncTokenIssued("jan"); !ok {
t.Errorf("FAIL: the live token is not reported")
}
if err := db.RevokeSyncToken("jan"); err != nil {
t.Fatal(err)
}
var open int
db.db.QueryRow(`SELECT count(*) FROM token WHERE account = 'jan' AND revoked = ''`).Scan(&open)
if open != 0 {
t.Errorf("FAIL: revoke-all left %d rows unrevoked", open)
}
}
// A disabled account's credentials sign nothing at the front, its
// hand-issued rows included.
func TestADisabledAccountSignsNothing(t *testing.T) {
db := tokenDB(t, "sandbox")
writerWithToken(t, db, "jan", "sandbox")
if err := db.SetSyncUser("sandbox", "jan", "issued-by-hand"); err != nil {
t.Fatal(err)
}
if err := db.SetDisabled("jan", true); err != nil {
t.Fatal(err)
}
if got := db.Secrets("sandbox", "jan"); len(got) != 0 {
t.Errorf("FAIL: a disabled account still signs: %d credentials", len(got))
}
// A sync_user row with no account behind it (a forge without
// accounts) still answers.
if err := db.SetSyncUser("sandbox", "nobody-account", "x"); err != nil {
t.Fatal(err)
}
if got := db.Secrets("sandbox", "nobody-account"); len(got) != 1 {
t.Errorf("FAIL: an accountless sync_user row stopped answering")
}
}
// After a rollback an older build replaces an account's sync_token row;
// the next open of this build retires the copy of the old value and
// copies the new one, once. Revoking a copied token clears its old row,
// so a rollback cannot bring it back.
func TestRollbackReplaceRetiresTheOldCopy(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "oltp.db")
open := func() *DB {
t.Helper()
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
return db
}
db := open()
if err := db.AddAccount("jan", "pw-jan-123", false); err != nil {
t.Fatal(err)
}
sealA, _ := db.seal.seal("VALUE-A")
sealB, _ := db.seal.seal("VALUE-B")
if _, err := db.w.Exec(`CREATE TABLE sync_token (user TEXT PRIMARY KEY, sealed TEXT NOT NULL, created TEXT NOT NULL)`); err != nil {
t.Fatal(err)
}
if _, err := db.w.Exec(`INSERT INTO sync_token VALUES ('jan', ?, '2026-03-01T00:00:00Z')`, sealA); err != nil {
t.Fatal(err)
}
db.Close()
db = open()
if _, ok := db.ResolveToken("jan", "VALUE-A", ScopeSync); !ok {
t.Fatalf("FAIL: the copied value does not resolve")
}
// The older build, rolled back to, mints again: it replaces the row.
if _, err := db.w.Exec(`UPDATE sync_token SET sealed = ? WHERE user = 'jan'`, sealB); err != nil {
t.Fatal(err)
}
db.Close()
db = open()
db.Close()
db = open() // twice: nothing is copied twice
defer db.Close()
if _, ok := db.ResolveToken("jan", "VALUE-A", ScopeSync); ok {
t.Errorf("FAIL: the value the older build replaced still resolves")
}
b, ok := db.ResolveToken("jan", "VALUE-B", ScopeSync)
if !ok {
t.Fatalf("FAIL: the older build's new value was not copied")
}
var n int
db.db.QueryRow(`SELECT count(*) FROM token WHERE account = 'jan' AND revoked = ''`).Scan(&n)
if n != 1 {
t.Errorf("FAIL: %d live copies, want 1", n)
}
// Revoking the copy clears the old row, so a rollback finds nothing.
if err := db.RevokeToken("jan", b.ID); err != nil {
t.Fatal(err)
}
db.db.QueryRow(`SELECT count(*) FROM sync_token WHERE user = 'jan'`).Scan(&n)
if n != 0 {
t.Errorf("FAIL: the revoked copy's sync_token row survives for a rollback to revive")
}
}