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