Skip to content
quarry Artifact [1ac5e017a8]
Login

Artifact [1ac5e017a8]

Artifact 1ac5e017a8d4c94e3c0e122643fd7ea87bc4e470dc02e490554647ab25aa671e:


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")
	}
}