package db
import (
"bytes"
"context"
"errors"
"testing"
)
func TestAgentTokenLifecycle(t *testing.T) {
s, pool, cleanup := newTestStore(t)
defer cleanup()
ctx := context.Background()
token, err := GenerateToken()
if err != nil {
t.Fatalf("generate token: %v", err)
}
hash := HashToken(token)
created, err := s.CreateAgentToken(ctx, "spec-writer", hash)
if err != nil {
t.Fatalf("create token: %v", err)
}
if created.ID == 0 || !created.Active() {
t.Fatalf("unexpected created token: %+v", created)
}
// The plaintext must never have reached the database.
var found int
if err := pool.QueryRowContext(ctx,
`SELECT count(*) FROM agent_token WHERE encode(token_hash, 'escape') LIKE '%' || $1 || '%'`,
token).Scan(&found); err != nil {
t.Fatalf("scan for plaintext: %v", err)
}
if found != 0 {
t.Fatal("the token plaintext is recoverable from the row")
}
if _, err := s.CreateAgentToken(ctx, "duplicate", hash); !errors.Is(err, ErrTokenExists) {
t.Fatalf("duplicate token = %v, want ErrTokenExists", err)
}
byHash, err := s.AgentTokenByHash(ctx, hash)
if err != nil {
t.Fatalf("lookup by hash: %v", err)
}
if byHash.ID != created.ID || byHash.Name != "spec-writer" || !bytes.Equal(byHash.Hash, hash) {
t.Fatalf("unexpected lookup: %+v", byHash)
}
if _, err := s.AgentTokenByHash(ctx, HashToken("nope")); !errors.Is(err, ErrNotFound) {
t.Fatalf("unknown hash = %v, want ErrNotFound", err)
}
auth, err := s.AuthenticateAgentToken(ctx, token)
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if auth.ID != created.ID {
t.Fatalf("authenticated the wrong token: %+v", auth)
}
if _, err := s.AuthenticateAgentToken(ctx, token+"x"); !errors.Is(err, ErrNotFound) {
t.Fatalf("wrong token = %v, want ErrNotFound", err)
}
if _, err := s.AuthenticateAgentToken(ctx, ""); !errors.Is(err, ErrNotFound) {
t.Fatalf("empty token = %v, want ErrNotFound", err)
}
// Revocation is a stamp, so the row stays for the audit trail.
if err := s.RevokeAgentToken(ctx, created.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := s.AuthenticateAgentToken(ctx, token); !errors.Is(err, ErrTokenRevoked) {
t.Fatalf("revoked token = %v, want ErrTokenRevoked", err)
}
revoked, err := s.AgentTokenByHash(ctx, hash)
if err != nil {
t.Fatalf("lookup revoked: %v", err)
}
if revoked.Active() || revoked.Revoked == nil {
t.Fatalf("revocation did not stick: %+v", revoked)
}
first := *revoked.Revoked
// Re-revoking keeps the original timestamp rather than moving it.
if err := s.RevokeAgentToken(ctx, created.ID); err != nil {
t.Fatalf("re-revoke: %v", err)
}
again, err := s.AgentTokenByHash(ctx, hash)
if err != nil {
t.Fatalf("lookup revoked: %v", err)
}
if !again.Revoked.Equal(first) {
t.Fatalf("re-revoking moved the timestamp: %v -> %v", first, *again.Revoked)
}
if err := s.RevokeAgentToken(ctx, 99999); !errors.Is(err, ErrNotFound) {
t.Fatalf("revoking a missing token = %v, want ErrNotFound", err)
}
second, err := GenerateToken()
if err != nil {
t.Fatalf("generate token: %v", err)
}
if _, err := s.CreateAgentToken(ctx, "notes-writer", HashToken(second)); err != nil {
t.Fatalf("create second token: %v", err)
}
list, err := s.ListAgentTokens(ctx)
if err != nil {
t.Fatalf("list tokens: %v", err)
}
if len(list) != 2 || list[0].Name != "notes-writer" {
t.Fatalf("unexpected token list (newest first): %+v", list)
}
if _, err := s.AuthenticateAgentToken(ctx, second); err != nil {
t.Fatalf("authenticate second token: %v", err)
}
}