사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
912 lines
28 KiB
Go
912 lines
28 KiB
Go
package credentialstore
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// newTestStore opens an in-memory SQLite store for tests.
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
s, err := Open(ctx, "file:iop_test_"+strings.ReplaceAll(t.Name(), "/", "_")+".db?mode=memory&cache=shared")
|
|
if err != nil {
|
|
t.Fatalf("open test store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
func TestCreatePrincipalReturnsRawTokenOnce(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "edge-prod"})
|
|
if err != nil {
|
|
t.Fatalf("create principal: %v", err)
|
|
}
|
|
if issued.Principal.Alias != "edge-prod" {
|
|
t.Fatalf("principal alias: got %q want %q", issued.Principal.Alias, "edge-prod")
|
|
}
|
|
if issued.Token.Status != StatusActive {
|
|
t.Fatalf("token status: got %q want %q", issued.Token.Status, StatusActive)
|
|
}
|
|
if issued.RawToken == "" {
|
|
t.Fatal("raw token must be returned once")
|
|
}
|
|
if len(issued.RawToken) != 64 {
|
|
t.Fatalf("raw token length: got %d want 64", len(issued.RawToken))
|
|
}
|
|
// TokenRecord must NOT contain the raw token; only digest.
|
|
if issued.Token.Digest == issued.RawToken {
|
|
t.Fatal("token digest must not equal raw token")
|
|
}
|
|
// Verify digest is SHA-256 of raw.
|
|
sum := sha256.Sum256([]byte(issued.RawToken))
|
|
expected := hex.EncodeToString(sum[:])
|
|
if issued.Token.Digest != expected {
|
|
t.Fatalf("digest mismatch: got %q want %q", issued.Token.Digest, expected)
|
|
}
|
|
// The raw token must not appear in any DB column.
|
|
var storedDigest string
|
|
if err := store.db.QueryRowContext(ctx, `SELECT digest FROM tokens WHERE principal_id=?`, issued.Principal.ID).Scan(&storedDigest); err != nil {
|
|
t.Fatalf("query digest: %v", err)
|
|
}
|
|
if storedDigest != expected {
|
|
t.Fatalf("stored digest: got %q want %q", storedDigest, expected)
|
|
}
|
|
}
|
|
|
|
func TestCreatePrincipalRejectsDuplicateAlias(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
if _, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "dup"}); err != nil {
|
|
t.Fatalf("first create: %v", err)
|
|
}
|
|
var dupErr error
|
|
if _, dupErr = store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "dup"}); dupErr != ErrPrincipalAlreadyExists {
|
|
t.Fatalf("expected ErrPrincipalAlreadyExists, got: %v", dupErr)
|
|
}
|
|
// Verify no second principal was created.
|
|
var count int
|
|
store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM principals`).Scan(&count)
|
|
if count != 1 {
|
|
t.Fatalf("principal count: got %d want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestCreateFirstPrincipalWithTokenRejectsNonEmptyStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
if _, err := store.CreateFirstPrincipalWithToken(ctx, CreatePrincipalInput{Alias: "first"}); err != nil {
|
|
t.Fatalf("first create: %v", err)
|
|
}
|
|
var dupErr error
|
|
if _, dupErr = store.CreateFirstPrincipalWithToken(ctx, CreatePrincipalInput{Alias: "second"}); !errors.Is(dupErr, ErrPrincipalAlreadyExists) {
|
|
t.Fatalf("expected ErrPrincipalAlreadyExists, got: %v", dupErr)
|
|
}
|
|
var count int
|
|
store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM principals`).Scan(&count)
|
|
if count != 1 {
|
|
t.Fatalf("principal count: got %d want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestCreateFirstPrincipalWithTokenAllowsExactlyOneConcurrentCaller(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpFile := filepath.Join(t.TempDir(), "concurrent_first.db")
|
|
initializer, err := Open(ctx, tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("initialize store: %v", err)
|
|
}
|
|
if err := initializer.Close(); err != nil {
|
|
t.Fatalf("close initialized store: %v", err)
|
|
}
|
|
|
|
const numCallers = 5
|
|
stores := openIndependentTestStores(t, ctx, tmpFile, numCallers)
|
|
start := make(chan struct{})
|
|
type result struct {
|
|
issued *IssuedPrincipal
|
|
err error
|
|
}
|
|
results := make(chan result, numCallers)
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < numCallers; i++ {
|
|
wg.Add(1)
|
|
alias := fmt.Sprintf("alias-%d", i)
|
|
store := stores[i]
|
|
go func(a string, s *Store) {
|
|
defer wg.Done()
|
|
<-start
|
|
issued, err := s.CreateFirstPrincipalWithToken(ctx, CreatePrincipalInput{Alias: a})
|
|
results <- result{issued: issued, err: err}
|
|
}(alias, store)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
var successes, alreadyExists, tokenOutputs int
|
|
for result := range results {
|
|
if result.err == nil {
|
|
successes++
|
|
if result.issued == nil || result.issued.RawToken == "" {
|
|
t.Fatal("successful first bootstrap must return one raw token")
|
|
}
|
|
tokenOutputs++
|
|
} else if errors.Is(result.err, ErrPrincipalAlreadyExists) {
|
|
alreadyExists++
|
|
} else {
|
|
t.Fatalf("unexpected error from concurrent caller: %v", result.err)
|
|
}
|
|
}
|
|
|
|
if successes != 1 || tokenOutputs != 1 || alreadyExists != numCallers-1 {
|
|
t.Fatalf("concurrent results: successes=%d tokenOutputs=%d alreadyExists=%d; want 1, 1, and %d", successes, tokenOutputs, alreadyExists, numCallers-1)
|
|
}
|
|
|
|
var principalCount, tokenCount int
|
|
if err := stores[0].db.QueryRowContext(ctx, `SELECT COUNT(*) FROM principals`).Scan(&principalCount); err != nil {
|
|
t.Fatalf("count principals: %v", err)
|
|
}
|
|
if err := stores[0].db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tokens`).Scan(&tokenCount); err != nil {
|
|
t.Fatalf("count tokens: %v", err)
|
|
}
|
|
if principalCount != 1 || tokenCount != 1 {
|
|
t.Fatalf("persisted counts: principals=%d tokens=%d; want 1 and 1", principalCount, tokenCount)
|
|
}
|
|
}
|
|
|
|
func openIndependentTestStores(t *testing.T, ctx context.Context, databaseURL string, count int) []*Store {
|
|
t.Helper()
|
|
stores := make([]*Store, 0, count)
|
|
for range count {
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
for _, opened := range stores {
|
|
_ = opened.Close()
|
|
}
|
|
t.Fatalf("open independent store: %v", err)
|
|
}
|
|
stores = append(stores, store)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, store := range stores {
|
|
_ = store.Close()
|
|
}
|
|
})
|
|
return stores
|
|
}
|
|
|
|
func TestPrincipalTokenLifecycleUsesRevisionCAS(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "cas-test"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Disable with correct revision.
|
|
_, err = store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0)
|
|
if err != nil {
|
|
t.Fatalf("disable: %v", err)
|
|
}
|
|
|
|
// Verify revision incremented.
|
|
var rev int64
|
|
store.db.QueryRowContext(ctx, `SELECT revision FROM tokens WHERE principal_id=?`, issued.Principal.ID).Scan(&rev)
|
|
if rev != 1 {
|
|
t.Fatalf("revision after disable: got %d want 1", rev)
|
|
}
|
|
|
|
// A stale revision takes precedence over the inactive status.
|
|
_, err = store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0)
|
|
if !errors.Is(err, ErrRevisionMismatch) {
|
|
t.Fatalf("expected ErrRevisionMismatch, got: %v", err)
|
|
}
|
|
|
|
// Verify revision is still 1 (no change from failed disable).
|
|
store.db.QueryRowContext(ctx, `SELECT revision FROM tokens WHERE principal_id=?`, issued.Principal.ID).Scan(&rev)
|
|
if rev != 1 {
|
|
t.Fatalf("revision after failed disable: got %d want 1", rev)
|
|
}
|
|
}
|
|
|
|
func TestRevokeDisabledToken(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "revoke-disabled"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if _, err := store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0); err != nil {
|
|
t.Fatalf("disable: %v", err)
|
|
}
|
|
revoked, err := store.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 1)
|
|
if err != nil {
|
|
t.Fatalf("revoke disabled token: %v", err)
|
|
}
|
|
if revoked.Status != StatusRevoked || revoked.Revision != 2 || revoked.RevokedAt == nil {
|
|
t.Fatalf("unexpected revoked record: %+v", revoked)
|
|
}
|
|
}
|
|
|
|
func TestRevokedTokenPersistsAcrossReopen(t *testing.T) {
|
|
ctx := context.Background()
|
|
databaseURL := t.TempDir() + "/revoked.db"
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "reopen-revoked"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if _, err := store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0); err != nil {
|
|
t.Fatalf("disable: %v", err)
|
|
}
|
|
if _, err := store.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 1); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if err := store.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
reopened, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
defer reopened.Close()
|
|
if _, err := reopened.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 2); !errors.Is(err, ErrTokenRevoked) {
|
|
t.Fatalf("revoked state did not persist: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTokenMutationLosingCASDoesNotSucceed(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
// Serializing the in-memory SQLite pool avoids lock errors while the two
|
|
// callers still race for the same revision through the public API.
|
|
store.db.SetMaxOpenConns(1)
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "concurrent-cas"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
start := make(chan struct{})
|
|
errs := make(chan error, 2)
|
|
var wg sync.WaitGroup
|
|
for range 2 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
_, err := store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0)
|
|
errs <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
var successes, revisionMismatches int
|
|
for err := range errs {
|
|
if err == nil {
|
|
successes++
|
|
continue
|
|
}
|
|
if errors.Is(err, ErrRevisionMismatch) {
|
|
revisionMismatches++
|
|
continue
|
|
}
|
|
t.Fatalf("CAS loser returned an untyped error: %v", err)
|
|
}
|
|
if successes != 1 || revisionMismatches != 1 {
|
|
t.Fatalf("CAS results: successes=%d revision_mismatches=%d", successes, revisionMismatches)
|
|
}
|
|
}
|
|
|
|
func TestRevokedTokenCannotBeReenabled(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "revoke-test"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Revoke the token.
|
|
_, err = store.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0)
|
|
if err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
|
|
// Attempt to re-enable (disable then active) must fail.
|
|
_, err = store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 1)
|
|
if err != ErrTokenRevoked {
|
|
t.Fatalf("expected ErrTokenRevoked on disable, got: %v", err)
|
|
}
|
|
|
|
// Attempt to re-revoke must also fail.
|
|
_, err = store.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 1)
|
|
if err != ErrTokenRevoked {
|
|
t.Fatalf("expected ErrTokenRevoked on revoke, got: %v", err)
|
|
}
|
|
|
|
// Verify status is revoked and revoked_at is set.
|
|
var status string
|
|
var revokedAt sql.NullString
|
|
store.db.QueryRowContext(ctx, `SELECT status, revoked_at FROM tokens WHERE principal_id=?`, issued.Principal.ID).Scan(&status, &revokedAt)
|
|
if status != StatusRevoked {
|
|
t.Fatalf("status: got %q want %q", status, StatusRevoked)
|
|
}
|
|
if !revokedAt.Valid {
|
|
t.Fatal("revoked_at must be set")
|
|
}
|
|
}
|
|
|
|
func TestPrincipalTokenPersistsAcrossReopen(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpFile := t.TempDir() + "/persist.db"
|
|
|
|
// Create and issue.
|
|
store1, err := Open(ctx, tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("open 1: %v", err)
|
|
}
|
|
issued, err := store1.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "persist"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
rawToken := issued.RawToken
|
|
_ = store1.Close()
|
|
|
|
// Reopen.
|
|
store2, err := Open(ctx, tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("open 2: %v", err)
|
|
}
|
|
defer store2.Close()
|
|
|
|
// Verify principal exists.
|
|
p, err := store2.GetPrincipal(ctx, "persist")
|
|
if err != nil {
|
|
t.Fatalf("get principal: %v", err)
|
|
}
|
|
if p == nil {
|
|
t.Fatal("principal must persist across reopen")
|
|
}
|
|
|
|
// Verify token lookup by digest works after reopen.
|
|
sum := sha256.Sum256([]byte(rawToken))
|
|
digest := hex.EncodeToString(sum[:])
|
|
_, token, err := store2.LookupTokenByDigest(ctx, digest)
|
|
if err != nil {
|
|
t.Fatalf("lookup by digest after reopen: %v", err)
|
|
}
|
|
if token == nil {
|
|
t.Fatal("token must persist across reopen")
|
|
}
|
|
if token.Status != StatusActive {
|
|
t.Fatalf("token status after reopen: got %q want %q", token.Status, StatusActive)
|
|
}
|
|
}
|
|
|
|
func TestLookupTokenByDigestReturnsOnlyActive(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "active-only"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
sum := sha256.Sum256([]byte(issued.RawToken))
|
|
digest := hex.EncodeToString(sum[:])
|
|
|
|
// Active lookup succeeds.
|
|
_, _, err = store.LookupTokenByDigest(ctx, digest)
|
|
if err != nil {
|
|
t.Fatalf("active lookup: %v", err)
|
|
}
|
|
|
|
// Disable and verify lookup fails.
|
|
_, err = store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0)
|
|
if err != nil {
|
|
t.Fatalf("disable: %v", err)
|
|
}
|
|
_, _, err = store.LookupTokenByDigest(ctx, digest)
|
|
if err != ErrTokenNotFound {
|
|
t.Fatalf("expected ErrTokenNotFound after disable, got: %v", err)
|
|
}
|
|
|
|
// Re-enable and verify lookup works again.
|
|
// Note: DisableToken transitions active->disabled, but we need a path
|
|
// to re-enable. For this test, we revoke and check that path.
|
|
// Actually the test verifies disable blocks lookup; re-enable is a
|
|
// separate concern tested elsewhere.
|
|
}
|
|
|
|
func TestListPrincipalsReturnsMetadataOnly(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "list-test"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
principals, err := store.ListPrincipals(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(principals) != 1 {
|
|
t.Fatalf("principal count: got %d want 1", len(principals))
|
|
}
|
|
p := principals[0]
|
|
if p.Principal.Alias != "list-test" {
|
|
t.Fatalf("alias: got %q want %q", p.Principal.Alias, "list-test")
|
|
}
|
|
// TokenRecord must not expose raw token.
|
|
if p.Token.TokenRef == issued.RawToken {
|
|
t.Fatal("list must not expose raw token in token_ref")
|
|
}
|
|
}
|
|
|
|
func TestListPrincipalsWithoutActiveToken(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "inactive-list"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if _, err := store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0); err != nil {
|
|
t.Fatalf("disable: %v", err)
|
|
}
|
|
|
|
principals, err := store.ListPrincipals(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list after disable: %v", err)
|
|
}
|
|
if len(principals) != 1 || principals[0].Token.TokenRef != "" || principals[0].Token.Revision != 0 {
|
|
t.Fatalf("inactive token metadata must be zero: %+v", principals)
|
|
}
|
|
if _, err := store.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 1); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
principals, err = store.ListPrincipals(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list after revoke: %v", err)
|
|
}
|
|
if len(principals) != 1 || principals[0].Token.TokenRef != "" {
|
|
t.Fatalf("revoked token metadata must be zero: %+v", principals)
|
|
}
|
|
}
|
|
|
|
func TestCreatePrincipalRequiresAlias(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
_, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty alias")
|
|
}
|
|
}
|
|
|
|
func TestRawTokenDoesNotLeakIntoDatabase(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "leak-test"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Scan every column in both tables and verify raw token is absent.
|
|
var principalAlias, principalID string
|
|
store.db.QueryRowContext(ctx, `SELECT id, alias FROM principals WHERE id=?`, issued.Principal.ID).Scan(&principalID, &principalAlias)
|
|
if principalAlias == issued.RawToken {
|
|
t.Fatal("raw token leaked into principals.alias")
|
|
}
|
|
|
|
var tokenRef, digest string
|
|
store.db.QueryRowContext(ctx, `SELECT token_ref, digest FROM tokens WHERE principal_id=?`, issued.Principal.ID).Scan(&tokenRef, &digest)
|
|
if strings.Contains(tokenRef, issued.RawToken) {
|
|
t.Fatal("raw token leaked into tokens.token_ref")
|
|
}
|
|
if strings.Contains(digest, issued.RawToken) {
|
|
t.Fatal("raw token leaked into tokens.digest")
|
|
}
|
|
// Verify no column contains the raw token.
|
|
var allText string
|
|
rows, err := store.db.QueryContext(ctx, `SELECT * FROM tokens WHERE principal_id=?`, issued.Principal.ID)
|
|
if err != nil {
|
|
t.Fatalf("scan tokens: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
cols, _ := rows.Columns()
|
|
for rows.Next() {
|
|
vals := make([]interface{}, len(cols))
|
|
ptrs := make([]interface{}, len(cols))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
t.Fatalf("scan row: %v", err)
|
|
}
|
|
for _, v := range vals {
|
|
if s, ok := v.(string); ok {
|
|
allText += s
|
|
}
|
|
}
|
|
}
|
|
if strings.Contains(allText, issued.RawToken) {
|
|
t.Fatal("raw token found in tokens table columns")
|
|
}
|
|
}
|
|
|
|
func TestRevocationIsIrreversible(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "irreversible"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Revoke.
|
|
_, err = store.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 0)
|
|
if err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
|
|
// Attempt to disable (which would be a step toward re-enabling).
|
|
_, err = store.DisableToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 1)
|
|
if err != ErrTokenRevoked {
|
|
t.Fatalf("expected ErrTokenRevoked, got: %v", err)
|
|
}
|
|
|
|
// Attempt to revoke again.
|
|
_, err = store.RevokeToken(ctx, issued.Principal.ID, issued.Token.TokenRef, 1)
|
|
if err != ErrTokenRevoked {
|
|
t.Fatalf("expected ErrTokenRevoked on double revoke, got: %v", err)
|
|
}
|
|
|
|
// Lookup must fail (only active tokens are returned).
|
|
sum := sha256.Sum256([]byte(issued.RawToken))
|
|
_, _, err = store.LookupTokenByDigest(ctx, hex.EncodeToString(sum[:]))
|
|
if err != ErrTokenNotFound {
|
|
t.Fatalf("expected ErrTokenNotFound for revoked token, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenReturnsNilForEmptyURL(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, err := Open(ctx, "")
|
|
if err != nil {
|
|
t.Fatalf("open empty: %v", err)
|
|
}
|
|
if s != nil {
|
|
t.Fatal("expected nil store for empty URL")
|
|
}
|
|
}
|
|
|
|
func TestDialectFromURLRejectsUnknownSchemeWithDBSuffix(t *testing.T) {
|
|
if _, err := dialectFromURL("memdb://credentials.db"); err == nil {
|
|
t.Fatal("unknown URL scheme containing .db must not select SQLite")
|
|
}
|
|
for _, tc := range []struct {
|
|
url, want string
|
|
}{
|
|
{"credentials.db", dialectSQLite},
|
|
{"file:credentials.db", dialectSQLite},
|
|
{"postgres://db/credentials", dialectPostgres},
|
|
{"postgresql://db/credentials", dialectPostgres},
|
|
} {
|
|
got, err := dialectFromURL(tc.url)
|
|
if err != nil || got != tc.want {
|
|
t.Fatalf("dialectFromURL(%q) = %q, %v; want %q", tc.url, got, err, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBindQueryUsesPostgresOrdinals(t *testing.T) {
|
|
query := "SELECT * FROM tokens WHERE principal_id=? AND token_ref=? AND status='active'"
|
|
if got, want := bindQuery(dialectPostgres, query), "SELECT * FROM tokens WHERE principal_id=$1 AND token_ref=$2 AND status='active'"; got != want {
|
|
t.Fatalf("PostgreSQL binding: got %q want %q", got, want)
|
|
}
|
|
if got := bindQuery(dialectSQLite, query); got != query {
|
|
t.Fatalf("SQLite binding changed query: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteDataSourceWithBusyTimeoutPreservesDataSourceShape(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, input, want string
|
|
}{
|
|
{"plain path", "credentials.db", "credentials.db?_pragma=busy_timeout%3d5000"},
|
|
{"file URL", "file:credentials.db", "file:credentials.db?_pragma=busy_timeout%3d5000"},
|
|
{"query", "file:credentials.db?mode=memory&cache=shared", "file:credentials.db?mode=memory&cache=shared&_pragma=busy_timeout%3d5000"},
|
|
{"memory", ":memory:", ":memory:?_pragma=busy_timeout%3d5000"},
|
|
{"fragment", "file:credentials.db?mode=rwc#fragment", "file:credentials.db?mode=rwc&_pragma=busy_timeout%3d5000#fragment"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := sqliteDataSourceWithBusyTimeout(tc.input); got != tc.want {
|
|
t.Fatalf("sqliteDataSourceWithBusyTimeout(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSchemaStatementsMatchDialect(t *testing.T) {
|
|
postgres := strings.Join(schemaStatements(dialectPostgres), "\n")
|
|
if strings.Contains(postgres, "DATETIME") || strings.Contains(postgres, "?") || !strings.Contains(postgres, "TIMESTAMPTZ") {
|
|
t.Fatalf("PostgreSQL schema is not executable: %s", postgres)
|
|
}
|
|
sqlite := strings.Join(schemaStatements(dialectSQLite), "\n")
|
|
if !strings.Contains(sqlite, "created_at TEXT") {
|
|
t.Fatalf("SQLite schema must use text timestamps: %s", sqlite)
|
|
}
|
|
}
|
|
|
|
func TestOpenRejectsUnsupportedScheme(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, err := Open(ctx, "memdb://something")
|
|
if err == nil {
|
|
t.Fatal("expected error for unsupported scheme")
|
|
}
|
|
}
|
|
|
|
func TestOpenRejectsUnreachablePostgres(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, err := Open(ctx, "postgres://localhost:99999/nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for unreachable postgres")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteReopenPreservesSchema(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmpFile := t.TempDir() + "/schema.db"
|
|
|
|
// First open creates schema.
|
|
s1, err := Open(ctx, tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("open 1: %v", err)
|
|
}
|
|
_, err = s1.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "schema-test"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
_ = s1.Close()
|
|
|
|
// Second open must not re-fail on schema.
|
|
s2, err := Open(ctx, tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("open 2: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
|
|
// Data must persist.
|
|
p, err := s2.GetPrincipal(ctx, "schema-test")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if p == nil {
|
|
t.Fatal("principal must persist")
|
|
}
|
|
}
|
|
|
|
func TestAdditionalTokenIssueAndList(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
gen0, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration 0: %v", err)
|
|
}
|
|
|
|
issuedP, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "add-token-principal"})
|
|
if err != nil {
|
|
t.Fatalf("CreatePrincipalWithToken: %v", err)
|
|
}
|
|
gen1, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration 1: %v", err)
|
|
}
|
|
if gen1 != gen0+1 {
|
|
t.Fatalf("ProjectionGeneration after CreatePrincipalWithToken: got %d want %d", gen1, gen0+1)
|
|
}
|
|
|
|
dig0 := digestOf(issuedP.RawToken)
|
|
if issuedP.Token.Digest != dig0 {
|
|
t.Fatalf("issuedP token digest mismatch: got %q want %q", issuedP.Token.Digest, dig0)
|
|
}
|
|
if issuedP.Token.TokenRef != tokenRefFor(dig0) {
|
|
t.Fatalf("issuedP tokenRef mismatch: got %q want %q", issuedP.Token.TokenRef, tokenRefFor(dig0))
|
|
}
|
|
|
|
tok1, err := store.CreateToken(ctx, issuedP.Principal.ID)
|
|
if err != nil {
|
|
t.Fatalf("CreateToken 1: %v", err)
|
|
}
|
|
gen2, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration 2: %v", err)
|
|
}
|
|
if gen2 != gen1+1 {
|
|
t.Fatalf("ProjectionGeneration after CreateToken 1: got %d want %d", gen2, gen1+1)
|
|
}
|
|
if tok1.RawToken == "" {
|
|
t.Fatal("RawToken must be returned on CreateToken")
|
|
}
|
|
if tok1.Token.PrincipalID != issuedP.Principal.ID {
|
|
t.Fatalf("PrincipalID mismatch: got %q want %q", tok1.Token.PrincipalID, issuedP.Principal.ID)
|
|
}
|
|
dig1 := digestOf(tok1.RawToken)
|
|
if tok1.Token.Digest != dig1 {
|
|
t.Fatalf("tok1 digest mismatch: got %q want %q", tok1.Token.Digest, dig1)
|
|
}
|
|
if tok1.Token.TokenRef != tokenRefFor(dig1) {
|
|
t.Fatalf("tok1 tokenRef mismatch: got %q want %q", tok1.Token.TokenRef, tokenRefFor(dig1))
|
|
}
|
|
|
|
tok2, err := store.CreateToken(ctx, issuedP.Principal.ID)
|
|
if err != nil {
|
|
t.Fatalf("CreateToken 2: %v", err)
|
|
}
|
|
gen3, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration 3: %v", err)
|
|
}
|
|
if gen3 != gen2+1 {
|
|
t.Fatalf("ProjectionGeneration after CreateToken 2: got %d want %d", gen3, gen2+1)
|
|
}
|
|
if tok2.RawToken == "" {
|
|
t.Fatal("RawToken must be returned on CreateToken 2")
|
|
}
|
|
if tok2.Token.PrincipalID != issuedP.Principal.ID {
|
|
t.Fatalf("PrincipalID mismatch: got %q want %q", tok2.Token.PrincipalID, issuedP.Principal.ID)
|
|
}
|
|
dig2 := digestOf(tok2.RawToken)
|
|
if tok2.Token.Digest != dig2 {
|
|
t.Fatalf("tok2 digest mismatch: got %q want %q", tok2.Token.Digest, dig2)
|
|
}
|
|
if tok2.Token.TokenRef != tokenRefFor(dig2) {
|
|
t.Fatalf("tok2 tokenRef mismatch: got %q want %q", tok2.Token.TokenRef, tokenRefFor(dig2))
|
|
}
|
|
|
|
rows, err := store.db.QueryContext(ctx, store.bind(`SELECT id, principal_id, token_ref, digest, status, revision FROM tokens WHERE principal_id=? ORDER BY created_at ASC, id ASC`), issuedP.Principal.ID)
|
|
if err != nil {
|
|
t.Fatalf("query tokens: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
expectedTokens := []struct {
|
|
id string
|
|
tokenRef string
|
|
digest string
|
|
raw string
|
|
status string
|
|
revision int64
|
|
}{
|
|
{id: issuedP.Token.ID, tokenRef: issuedP.Token.TokenRef, digest: dig0, raw: issuedP.RawToken, status: StatusActive, revision: 0},
|
|
{id: tok1.Token.ID, tokenRef: tok1.Token.TokenRef, digest: dig1, raw: tok1.RawToken, status: StatusActive, revision: 0},
|
|
{id: tok2.Token.ID, tokenRef: tok2.Token.TokenRef, digest: dig2, raw: tok2.RawToken, status: StatusActive, revision: 0},
|
|
}
|
|
|
|
var rowCount int
|
|
for rows.Next() {
|
|
if rowCount >= len(expectedTokens) {
|
|
t.Fatalf("too many persisted token rows: count > %d", len(expectedTokens))
|
|
}
|
|
exp := expectedTokens[rowCount]
|
|
var id, pid, tref, dig, status string
|
|
var rev int64
|
|
if err := rows.Scan(&id, &pid, &tref, &dig, &status, &rev); err != nil {
|
|
t.Fatalf("scan token row %d: %v", rowCount, err)
|
|
}
|
|
if id != exp.id {
|
|
t.Errorf("row %d ID mismatch: got %q want %q", rowCount, id, exp.id)
|
|
}
|
|
if pid != issuedP.Principal.ID {
|
|
t.Errorf("row %d PrincipalID mismatch: got %q want %q", rowCount, pid, issuedP.Principal.ID)
|
|
}
|
|
if tref != exp.tokenRef {
|
|
t.Errorf("row %d TokenRef mismatch: got %q want %q", rowCount, tref, exp.tokenRef)
|
|
}
|
|
if dig != exp.digest {
|
|
t.Errorf("row %d Digest mismatch: got %q want %q", rowCount, dig, exp.digest)
|
|
}
|
|
if status != exp.status {
|
|
t.Errorf("row %d Status mismatch: got %q want %q", rowCount, status, exp.status)
|
|
}
|
|
if rev != exp.revision {
|
|
t.Errorf("row %d Revision mismatch: got %d want %d", rowCount, rev, exp.revision)
|
|
}
|
|
if strings.Contains(dig, exp.raw) || strings.Contains(tref, exp.raw) {
|
|
t.Errorf("row %d leaks raw token %q in digest/token_ref", rowCount, exp.raw)
|
|
}
|
|
rowCount++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("token rows iteration: %v", err)
|
|
}
|
|
if rowCount != len(expectedTokens) {
|
|
t.Fatalf("persisted token rows count: got %d want %d", rowCount, len(expectedTokens))
|
|
}
|
|
|
|
list, err := store.ListTokens(ctx, issuedP.Principal.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListTokens: %v", err)
|
|
}
|
|
if len(list) != 3 {
|
|
t.Fatalf("ListTokens count: got %d want 3", len(list))
|
|
}
|
|
for i, exp := range expectedTokens {
|
|
if list[i].ID != exp.id {
|
|
t.Errorf("list[%d] ID mismatch: got %q want %q", i, list[i].ID, exp.id)
|
|
}
|
|
if list[i].TokenRef != exp.tokenRef {
|
|
t.Errorf("list[%d] TokenRef mismatch: got %q want %q", i, list[i].TokenRef, exp.tokenRef)
|
|
}
|
|
if list[i].Digest != exp.digest {
|
|
t.Errorf("list[%d] Digest mismatch: got %q want %q", i, list[i].Digest, exp.digest)
|
|
}
|
|
if list[i].Status != exp.status {
|
|
t.Errorf("list[%d] Status mismatch: got %q want %q", i, list[i].Status, exp.status)
|
|
}
|
|
if list[i].Revision != exp.revision {
|
|
t.Errorf("list[%d] Revision mismatch: got %d want %d", i, list[i].Revision, exp.revision)
|
|
}
|
|
}
|
|
|
|
genBeforeFail, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration before fail: %v", err)
|
|
}
|
|
countBeforeFail := len(list)
|
|
|
|
_, err = store.CreateToken(ctx, "non-existent-principal-id")
|
|
if !errors.Is(err, ErrPrincipalNotFound) {
|
|
t.Fatalf("expected ErrPrincipalNotFound for non-existent principal, got: %v", err)
|
|
}
|
|
|
|
genAfterFail, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration after fail: %v", err)
|
|
}
|
|
if genAfterFail != genBeforeFail {
|
|
t.Fatalf("ProjectionGeneration changed on rejected CreateToken: got %d want %d", genAfterFail, genBeforeFail)
|
|
}
|
|
|
|
listAfterFail, err := store.ListTokens(ctx, issuedP.Principal.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListTokens after fail: %v", err)
|
|
}
|
|
if len(listAfterFail) != countBeforeFail {
|
|
t.Fatalf("ListTokens count changed on rejected CreateToken: got %d want %d", len(listAfterFail), countBeforeFail)
|
|
}
|
|
}
|
|
|
|
// Compile-time interface check.
|
|
var _ = (*Store)(nil)
|
|
|
|
// Ensure time import is used.
|
|
var _ = time.Now
|