954 lines
28 KiB
Go
954 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHandshakeResponse(t *testing.T) {
|
|
s := newServer()
|
|
resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":7,"method":"handshake","params":{"appVersion":"dev"}}`)
|
|
if shutdown {
|
|
t.Fatal("handshake should not shut down the server")
|
|
}
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error)
|
|
}
|
|
data, err := json.Marshal(resp.Result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var result struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
AgentProtocolVersion int `json:"agentProtocolVersion"`
|
|
Capabilities []string `json:"capabilities"`
|
|
}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ProtocolVersion != 1 || result.AgentProtocolVersion != 1 {
|
|
t.Fatalf("unexpected protocol versions: %+v", result)
|
|
}
|
|
contract := protocolContract(t)
|
|
if result.ProtocolVersion != contract.ProtocolVersion || result.AgentProtocolVersion != contract.ProtocolVersion {
|
|
t.Fatalf("handshake protocol versions do not match contract: result=%+v contract=%+v", result, contract)
|
|
}
|
|
for _, capability := range result.Capabilities {
|
|
if !contains(contract.AllCapabilities, capability) {
|
|
t.Fatalf("handshake returned capability %q outside protocol contract %v", capability, contract.AllCapabilities)
|
|
}
|
|
}
|
|
if !contains(result.Capabilities, "query") || !contains(result.Capabilities, "metadata") {
|
|
t.Fatalf("expected query and metadata capabilities, got %v", result.Capabilities)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeHandshakeAdvertisesMultiSessionProtocol(t *testing.T) {
|
|
runtime := newRuntimeServer()
|
|
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":7,"method":"handshake","params":{}}`)
|
|
if shutdown || resp.Error != nil {
|
|
t.Fatalf("unexpected handshake response: shutdown=%v error=%v", shutdown, resp.Error)
|
|
}
|
|
data, err := json.Marshal(resp.Result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var result struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
Capabilities []string `json:"capabilities"`
|
|
}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ProtocolVersion != multiSessionProtocolVersion || !contains(result.Capabilities, "multi_session") {
|
|
t.Fatalf("unexpected runtime handshake: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeMissingAgentSessionDoesNotUseQueryCursorSessionID(t *testing.T) {
|
|
runtime := newRuntimeServer()
|
|
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":8,"method":"fetch_query_page","params":{"sessionId":"cursor-1"}}`)
|
|
if shutdown {
|
|
t.Fatal("fetch_query_page should not shut down the runtime")
|
|
}
|
|
if resp.Error == nil || !strings.Contains(resp.Error.Message, legacyAgentSessionID) {
|
|
t.Fatalf("expected missing legacy agent session error, got %#v", resp.Error)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCloseOneSessionKeepsOtherSessionRegistered(t *testing.T) {
|
|
runtime := newRuntimeServer()
|
|
runtime.sessions["a"] = &agentSession{server: newServer()}
|
|
runtime.sessions["b"] = &agentSession{server: newServer()}
|
|
|
|
if err := runtime.closeSession("a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := runtime.session("a"); err == nil {
|
|
t.Fatal("closed session should be removed")
|
|
}
|
|
if _, err := runtime.session("b"); err != nil {
|
|
t.Fatalf("other session should remain registered: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCancelSessionOnlyCancelsTargetSession(t *testing.T) {
|
|
runtime := newRuntimeServer()
|
|
serverA := newServer()
|
|
serverB := newServer()
|
|
ctxA, cancelA := context.WithCancel(context.Background())
|
|
ctxB, cancelB := context.WithCancel(context.Background())
|
|
serverA.activeCancel = cancelA
|
|
serverB.activeCancel = cancelB
|
|
runtime.sessions["a"] = &agentSession{server: serverA}
|
|
runtime.sessions["b"] = &agentSession{server: serverB}
|
|
|
|
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":9,"method":"cancel_session","params":{"agentSessionId":"a"}}`)
|
|
if shutdown || resp.Error != nil {
|
|
t.Fatalf("unexpected cancel response: shutdown=%v error=%v", shutdown, resp.Error)
|
|
}
|
|
select {
|
|
case <-ctxA.Done():
|
|
default:
|
|
t.Fatal("target session was not canceled")
|
|
}
|
|
select {
|
|
case <-ctxB.Done():
|
|
t.Fatal("canceling session a should not cancel session b")
|
|
default:
|
|
}
|
|
cancelB()
|
|
}
|
|
|
|
func TestRuntimeRejectsSessionsBeyondLimit(t *testing.T) {
|
|
runtime := newRuntimeServer()
|
|
for index := 0; index < maxAgentSessions; index++ {
|
|
runtime.sessions[fmt.Sprintf("session-%d", index)] = &agentSession{server: newServer()}
|
|
}
|
|
err := runtime.openSession("overflow", connectParams{})
|
|
if err == nil || !strings.Contains(err.Error(), "session limit") {
|
|
t.Fatalf("expected session limit error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewXuguDatabaseSessionFindsOnlyNewSession(t *testing.T) {
|
|
existing := xuguDatabaseSession{nodeID: 1, sessionID: 10}
|
|
created := xuguDatabaseSession{nodeID: 1, sessionID: 11}
|
|
result, err := newXuguDatabaseSession(
|
|
map[xuguDatabaseSession]struct{}{existing: {}},
|
|
map[xuguDatabaseSession]struct{}{existing: {}, created: {}},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != created {
|
|
t.Fatalf("unexpected session: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestXuguSessionAppNameIsStableAndDoesNotExposeSessionID(t *testing.T) {
|
|
name := xuguSessionAppName("tab-session-secret")
|
|
if name != xuguSessionAppName("tab-session-secret") {
|
|
t.Fatal("app name should be stable")
|
|
}
|
|
if strings.Contains(name, "tab-session-secret") || !strings.HasPrefix(name, "DBX_") {
|
|
t.Fatalf("unexpected app name: %s", name)
|
|
}
|
|
}
|
|
|
|
func TestCloseMissingQuerySessionReturnsFalse(t *testing.T) {
|
|
s := newServer()
|
|
resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":8,"method":"close_query_session","params":{"sessionId":"missing"}}`)
|
|
if shutdown {
|
|
t.Fatal("close_query_session should not shut down the server")
|
|
}
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error)
|
|
}
|
|
if resp.Result != false {
|
|
t.Fatalf("expected false result, got %#v", resp.Result)
|
|
}
|
|
}
|
|
|
|
func TestListDataTypesReturnsXuguTypes(t *testing.T) {
|
|
s := newServer()
|
|
resp, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":9,"method":"list_data_types","params":{"database":"demo"}}`)
|
|
if shutdown {
|
|
t.Fatal("list_data_types should not shut down the server")
|
|
}
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %v", resp.Error)
|
|
}
|
|
data, err := json.Marshal(resp.Result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var result []string
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"INTEGER", "VARCHAR", "NUMERIC", "INT"} {
|
|
if !contains(result, want) {
|
|
t.Fatalf("expected data type %q in %v", want, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmptyResultSlicesMarshalAsArrays(t *testing.T) {
|
|
data, err := json.Marshal(queryResult{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(data)
|
|
if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"column_types":null`) || strings.Contains(text, `"rows":null`) {
|
|
t.Fatalf("query result should marshal nil slices as arrays: %s", text)
|
|
}
|
|
|
|
data, err = json.Marshal(indexInfo{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text = string(data)
|
|
if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"included_columns":null`) {
|
|
t.Fatalf("index info should marshal nil slices as arrays: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestGetTableDDLResultMarshalsAsString(t *testing.T) {
|
|
data, err := json.Marshal("CREATE TABLE SYSDBA.ORDERS (ID INT)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var ddl string
|
|
if err := json.Unmarshal(data, &ddl); err != nil {
|
|
t.Fatalf("get_table_ddl result must deserialize as a string: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNUsesConnectionStringWhenProvided(t *testing.T) {
|
|
dsn := buildDSN(connectParams{ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138"})
|
|
|
|
if dsn != "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138" {
|
|
t.Fatalf("unexpected dsn: %s", dsn)
|
|
}
|
|
}
|
|
|
|
func protocolContract(t *testing.T) struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
AllCapabilities []string `json:"allCapabilities"`
|
|
} {
|
|
t.Helper()
|
|
data, err := os.ReadFile("../../common/src/main/resources/agent-protocol-v1.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var contract struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
AllCapabilities []string `json:"allCapabilities"`
|
|
}
|
|
if err := json.Unmarshal(data, &contract); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return contract
|
|
}
|
|
|
|
func TestBuildDSNUsesConnectionFields(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "db.example.com",
|
|
Port: 15138,
|
|
Database: "demo",
|
|
Username: "sysdba",
|
|
Password: "secret",
|
|
})
|
|
|
|
for _, part := range []string{"IP=db.example.com", "DB=demo", "User=sysdba", "PWD=secret", "Port=15138"} {
|
|
if !strings.Contains(dsn, part) {
|
|
t.Fatalf("dsn should contain %s, got: %s", part, dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNUsesDefaultPort(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "db.example.com",
|
|
Database: "demo",
|
|
Username: "sysdba",
|
|
Password: "secret",
|
|
})
|
|
|
|
if !strings.Contains(dsn, "Port=5138") {
|
|
t.Fatalf("dsn should default to Xugu port, got: %s", dsn)
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNParsesJdbcURL(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Username: "sysdba",
|
|
Password: "secret",
|
|
ConnectionString: "jdbc:xugu://db.example.com:15138/demo",
|
|
})
|
|
|
|
for _, part := range []string{"IP=db.example.com", "DB=demo", "User=sysdba", "PWD=secret", "Port=15138"} {
|
|
if !strings.Contains(dsn, part) {
|
|
t.Fatalf("dsn should contain %s, got: %s", part, dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNParsesDBXURL(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
ConnectionString: "xugu://sysdba:secret@db.example.com:15138/demo",
|
|
})
|
|
|
|
for _, part := range []string{"IP=db.example.com", "DB=demo", "User=sysdba", "PWD=secret", "Port=15138"} {
|
|
if !strings.Contains(dsn, part) {
|
|
t.Fatalf("dsn should contain %s, got: %s", part, dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNAppendsURLParams(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "db.example.com",
|
|
Database: "demo",
|
|
Username: "sysdba",
|
|
Password: "secret",
|
|
URLParams: "AUTO_COMMIT=on;CHAR_SET=UTF8",
|
|
})
|
|
|
|
for _, part := range []string{"AUTO_COMMIT=on", "CHAR_SET=UTF8"} {
|
|
if !strings.Contains(dsn, part) {
|
|
t.Fatalf("dsn should contain %s, got: %s", part, dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNDefaultsToUTF8(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "db.example.com",
|
|
Database: "demo",
|
|
Username: "sysdba",
|
|
Password: "secret",
|
|
})
|
|
|
|
if !strings.Contains(dsn, "CHAR_SET=UTF8") {
|
|
t.Fatalf("dsn should default to UTF8, got: %s", dsn)
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNRespectsExplicitCharset(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "db.example.com",
|
|
Database: "demo",
|
|
Username: "sysdba",
|
|
Password: "secret",
|
|
URLParams: "CHAR_SET=GBK",
|
|
})
|
|
|
|
if strings.Contains(dsn, "CHAR_SET=UTF8") || !strings.Contains(dsn, "CHAR_SET=GBK") {
|
|
t.Fatalf("dsn should respect explicit charset, got: %s", dsn)
|
|
}
|
|
}
|
|
|
|
func TestListDatabasesSQLUsesXuguDictionary(t *testing.T) {
|
|
sqlText := strings.ToUpper(xuguListDatabasesSQL)
|
|
|
|
if !strings.Contains(sqlText, "ALL_DATABASES") || strings.Contains(sqlText, "SYS_DATABASES") {
|
|
t.Fatalf("database listing should query low-privilege ALL_DATABASES, got: %s", xuguListDatabasesSQL)
|
|
}
|
|
}
|
|
|
|
func TestFallbackDatabasesFromParams(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
params connectParams
|
|
want string
|
|
}{
|
|
{
|
|
name: "database field",
|
|
params: connectParams{
|
|
Database: "LOWPRIV",
|
|
},
|
|
want: "LOWPRIV",
|
|
},
|
|
{
|
|
name: "dbx url",
|
|
params: connectParams{
|
|
ConnectionString: "xugu://user:secret@db.example.com:5138/demo",
|
|
},
|
|
want: "demo",
|
|
},
|
|
{
|
|
name: "jdbc url",
|
|
params: connectParams{
|
|
ConnectionString: "jdbc:xugu://db.example.com:5138/reporting",
|
|
},
|
|
want: "reporting",
|
|
},
|
|
{
|
|
name: "native dsn",
|
|
params: connectParams{
|
|
ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138",
|
|
},
|
|
want: "SYSTEM",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := fallbackDatabasesFromParams(tc.params)
|
|
if len(got) != 1 || got[0].Name != tc.want {
|
|
t.Fatalf("unexpected fallback databases: got=%v want=%s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUseDatabaseSkipsConfiguredDatabase(t *testing.T) {
|
|
s := newServer()
|
|
s.params = connectParams{Database: "SYSTEM"}
|
|
|
|
if err := s.useDatabase("system"); err != nil {
|
|
t.Fatalf("expected configured database USE to be skipped, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConfiguredDatabaseName(t *testing.T) {
|
|
cases := []struct {
|
|
params connectParams
|
|
want string
|
|
}{
|
|
{params: connectParams{Database: "SYSTEM"}, want: "SYSTEM"},
|
|
{params: connectParams{ConnectionString: "xugu://user:secret@db.example.com:5138/demo"}, want: "demo"},
|
|
{params: connectParams{ConnectionString: "jdbc:xugu://db.example.com:5138/reporting"}, want: "reporting"},
|
|
{params: connectParams{ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret"}, want: "SYSTEM"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
if got := configuredDatabaseName(tc.params); got != tc.want {
|
|
t.Fatalf("configuredDatabaseName(%+v) = %q, want %q", tc.params, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSchemaListingSQLUsesLowPrivilegeDictionary(t *testing.T) {
|
|
sqlText := strings.ToUpper(xuguListSchemasSQL)
|
|
|
|
if !strings.Contains(sqlText, "ALL_SCHEMAS") || strings.Contains(sqlText, "SYS_SCHEMAS") {
|
|
t.Fatalf("schema listing should query low-privilege ALL_SCHEMAS, got: %s", xuguListSchemasSQL)
|
|
}
|
|
}
|
|
|
|
func TestPrimaryKeySQLUsesLowPrivilegeDictionary(t *testing.T) {
|
|
sqlText := strings.ToUpper(xuguPrimaryKeyColumnsSQL)
|
|
|
|
for _, want := range []string{"ALL_CONSTRAINTS", "ALL_TABLES", "ALL_SCHEMAS"} {
|
|
if !strings.Contains(sqlText, want) {
|
|
t.Fatalf("primary key listing should query %s, got: %s", want, xuguPrimaryKeyColumnsSQL)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"SYS_CONSTRAINTS", "SYS_TABLES", "SYS_SCHEMAS"} {
|
|
if strings.Contains(sqlText, forbidden) {
|
|
t.Fatalf("primary key listing should not query %s, got: %s", forbidden, xuguPrimaryKeyColumnsSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestColumnSQLUsesLowPrivilegeDictionary(t *testing.T) {
|
|
sqlText := strings.ToUpper(xuguListColumnsSQL)
|
|
|
|
for _, want := range []string{"ALL_COLUMNS", "ALL_TABLES", "ALL_SCHEMAS", "COMMENTS", `"VARYING"`} {
|
|
if !strings.Contains(sqlText, want) {
|
|
t.Fatalf("column listing should query %s, got: %s", want, xuguListColumnsSQL)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"SYS_COLUMNS", "SYS_TABLES", "SYS_SCHEMAS"} {
|
|
if strings.Contains(sqlText, forbidden) {
|
|
t.Fatalf("column listing should not query %s, got: %s", forbidden, xuguListColumnsSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIndexSQLUsesLowPrivilegeDictionary(t *testing.T) {
|
|
sqlText := strings.ToUpper(xuguListIndexesSQL)
|
|
|
|
for _, want := range []string{"ALL_INDEXES", "ALL_TABLES", "ALL_SCHEMAS", "KEYS"} {
|
|
if !strings.Contains(sqlText, want) {
|
|
t.Fatalf("index listing should query %s, got: %s", want, xuguListIndexesSQL)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"SYS_INDEXES", "SYS_TABLES", "SYS_SCHEMAS"} {
|
|
if strings.Contains(sqlText, forbidden) {
|
|
t.Fatalf("index listing should not query %s, got: %s", forbidden, xuguListIndexesSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestXuguMetadataAccessErrorDetection(t *testing.T) {
|
|
if !isXuguMetadataAccessError(errors.New("[E18012] 权限不够")) {
|
|
t.Fatal("expected E18012 permission error to be treated as metadata access error")
|
|
}
|
|
if isXuguMetadataAccessError(errors.New("network timeout")) {
|
|
t.Fatal("network errors should not trigger database-list fallback")
|
|
}
|
|
}
|
|
|
|
func TestXuguListTablesQueryAppliesMetadataConstraints(t *testing.T) {
|
|
query := xuguListTablesQuery("APP", metadataListConstraints{
|
|
Filter: "ord_",
|
|
ObjectTypes: []string{"view", "table", "VIEW"},
|
|
Limit: 25,
|
|
Offset: 50,
|
|
})
|
|
|
|
for _, want := range []string{
|
|
"UPPER(TABLE_NAME) LIKE ? ESCAPE '\\'",
|
|
"TABLE_TYPE IN (?,?)",
|
|
"ORDER BY TABLE_TYPE, TABLE_NAME",
|
|
"ROWNUM <= ?",
|
|
"DBX_RN > ?",
|
|
} {
|
|
if !strings.Contains(query.SQL, want) {
|
|
t.Fatalf("expected SQL to contain %q:\n%s", want, query.SQL)
|
|
}
|
|
}
|
|
|
|
wantArgs := []any{"APP", "APP", `%O%R%D%\_%`, "TABLE", "VIEW", 75, 50}
|
|
assertArgs(t, query.Args, wantArgs)
|
|
}
|
|
|
|
func TestXuguListObjectsQueryRejectsUnsupportedObjectTypes(t *testing.T) {
|
|
query := xuguListObjectsQuery("APP", metadataListConstraints{
|
|
ObjectTypes: []string{"INDEX"},
|
|
Limit: 10,
|
|
})
|
|
|
|
if !strings.Contains(query.SQL, "1 = 0") {
|
|
t.Fatalf("unsupported object type should produce empty-result predicate:\n%s", query.SQL)
|
|
}
|
|
|
|
wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", 10, 0}
|
|
assertArgs(t, query.Args, wantArgs)
|
|
}
|
|
|
|
func TestXuguListObjectsQueryIncludesProgrammableObjects(t *testing.T) {
|
|
query := xuguListObjectsQuery("APP", metadataListConstraints{
|
|
ObjectTypes: []string{"procedure", "function", "package", "package-body", "trigger", "sequence", "type", "type-body"},
|
|
})
|
|
|
|
for _, want := range []string{"ALL_PROCEDURES", "p.VALID", "ALL_PACKAGES", "p.BODY IS NOT NULL", "ALL_TRIGGERS", "ALL_SEQUENCES", "ALL_TYPES", "u.BODY IS NOT NULL", "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", "OBJECT_TYPE IN (?,?,?,?,?,?,?,?)"} {
|
|
if !strings.Contains(query.SQL, want) {
|
|
t.Fatalf("expected SQL to contain %q:\n%s", want, query.SQL)
|
|
}
|
|
}
|
|
|
|
wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "PROCEDURE", "SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"}
|
|
assertArgs(t, query.Args, wantArgs)
|
|
}
|
|
|
|
func TestXuguObjectSourceQuerySupportsSharedObjectKinds(t *testing.T) {
|
|
for _, objectType := range []string{"TRIGGER", "PACKAGE_BODY", "TYPE", "TYPE_BODY"} {
|
|
query, _, err := objectSourceQuery("APP", "demo", objectType)
|
|
if err != nil {
|
|
t.Fatalf("%s should support object source lookup: %v", objectType, err)
|
|
}
|
|
if strings.TrimSpace(query) == "" {
|
|
t.Fatalf("%s should produce source SQL", objectType)
|
|
}
|
|
}
|
|
|
|
packageBodyQuery, _, err := objectSourceQuery("APP", "demo", "PACKAGE_BODY")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(packageBodyQuery, "TO_CHAR(k.BODY)") || strings.Contains(packageBodyQuery, "k.SPEC") {
|
|
t.Fatalf("package body query must request only the body: %s", packageBodyQuery)
|
|
}
|
|
|
|
typeSpecQuery, _, err := objectSourceQuery("APP", "demo", "TYPE")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(typeSpecQuery, "ALL_TYPES") || !strings.Contains(typeSpecQuery, "TO_CHAR(u.SPEC)") {
|
|
t.Fatalf("type query must return catalog SPEC content: %s", typeSpecQuery)
|
|
}
|
|
|
|
typeBodyQuery, _, err := objectSourceQuery("APP", "demo", "TYPE_BODY")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(typeBodyQuery, "ALL_TYPES") || !strings.Contains(typeBodyQuery, "TO_CHAR(u.BODY)") || !strings.Contains(typeBodyQuery, "u.BODY IS NOT NULL") {
|
|
t.Fatalf("type body query must return catalog BODY content: %s", typeBodyQuery)
|
|
}
|
|
}
|
|
|
|
func TestMetadataListConstraintsFromParams(t *testing.T) {
|
|
params := map[string]json.RawMessage{
|
|
"filter": json.RawMessage(`"tab"`),
|
|
"limit": json.RawMessage(`30`),
|
|
"offset": json.RawMessage(`5`),
|
|
"object_types": json.RawMessage(`["TABLE","VIEW"]`),
|
|
}
|
|
|
|
constraints := metadataListConstraintsFromParams(params)
|
|
if constraints.Filter != "tab" || constraints.Limit != 30 || constraints.Offset != 5 {
|
|
t.Fatalf("unexpected constraints: %+v", constraints)
|
|
}
|
|
if len(constraints.ObjectTypes) != 2 || constraints.ObjectTypes[0] != "TABLE" || constraints.ObjectTypes[1] != "VIEW" {
|
|
t.Fatalf("unexpected object types: %+v", constraints.ObjectTypes)
|
|
}
|
|
}
|
|
|
|
func assertArgs(t *testing.T, got []any, want []any) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("args length = %d, want %d: got=%#v want=%#v", len(got), len(want), got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("arg %d = %#v, want %#v; args=%#v", i, got[i], want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseForeignKeyColumns(t *testing.T) {
|
|
local, ref := parseForeignKeyColumns(`("C1","C2")("ID1","ID2")`)
|
|
|
|
if strings.Join(local, ",") != "C1,C2" || strings.Join(ref, ",") != "ID1,ID2" {
|
|
t.Fatalf("unexpected foreign key columns: local=%v ref=%v", local, ref)
|
|
}
|
|
}
|
|
|
|
func TestDecodeXuguScale(t *testing.T) {
|
|
numericScale := 32*65536 + 6
|
|
precision, scale, length := decodeXuguScale("NUMERIC", &numericScale)
|
|
if precision == nil || *precision != 32 || scale == nil || *scale != 6 || length != nil {
|
|
t.Fatalf("unexpected numeric scale decode: precision=%v scale=%v length=%v", precision, scale, length)
|
|
}
|
|
|
|
charScale := 128
|
|
precision, scale, length = decodeXuguScale("VARCHAR", &charScale)
|
|
if precision != nil || scale != nil || length == nil || *length != 128 {
|
|
t.Fatalf("unexpected char scale decode: precision=%v scale=%v length=%v", precision, scale, length)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeXuguColumnTypeUsesVaryingFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dataType string
|
|
varying any
|
|
want string
|
|
}{
|
|
{name: "varying char", dataType: "CHAR", varying: true, want: "VARCHAR"},
|
|
{name: "fixed char", dataType: "CHAR", varying: false, want: "CHAR"},
|
|
{name: "varying binary", dataType: "BINARY", varying: true, want: "VARBINARY"},
|
|
{name: "fixed binary", dataType: "BINARY", varying: false, want: "BINARY"},
|
|
{name: "other varying type", dataType: "NUMERIC", varying: true, want: "NUMERIC"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := normalizeXuguColumnType(tt.dataType, tt.varying); got != tt.want {
|
|
t.Fatalf("normalizeXuguColumnType(%q, %v) = %q, want %q", tt.dataType, tt.varying, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppendDDLStatement(t *testing.T) {
|
|
got := appendDDLStatement("CREATE TABLE \"T\" (\"ID\" INT)\n", "CREATE INDEX \"IDX\" ON \"T\"(\"ID\");")
|
|
want := "CREATE TABLE \"T\" (\"ID\" INT);\n\nCREATE INDEX \"IDX\" ON \"T\"(\"ID\");"
|
|
|
|
if got != want {
|
|
t.Fatalf("unexpected DDL append:\ngot: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestQuoteStringLiteralEscapesSingleQuotes(t *testing.T) {
|
|
if got := quoteStringLiteral("owner's note"); got != "'owner''s note'" {
|
|
t.Fatalf("unexpected quoted string: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeValuePreservesDriverNumericTypes(t *testing.T) {
|
|
if value := normalizeValue(int32(7)); value != int64(7) {
|
|
t.Fatalf("expected int32 to normalize to int64, got %#v", value)
|
|
}
|
|
if value := normalizeValue(float32(1.25)); value != float64(float32(1.25)) {
|
|
t.Fatalf("expected float32 to normalize to float64, got %#v", value)
|
|
}
|
|
}
|
|
|
|
func contains(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// -- fake drivers for timeout tests --
|
|
|
|
func init() {
|
|
sql.Register("xugu-test-blocking", &xuguBlockingDriver{})
|
|
sql.Register("xugu-test-fast", &xuguFastDriver{})
|
|
}
|
|
|
|
var xuguBlockingUnblock chan struct{}
|
|
|
|
// resetXuguBlockingDriver creates a fresh unblock channel for the blocking
|
|
// driver. Call before each test that uses "xugu-test-blocking".
|
|
func resetXuguBlockingDriver() {
|
|
xuguBlockingUnblock = make(chan struct{})
|
|
}
|
|
|
|
type xuguBlockingDriver struct{}
|
|
|
|
func (d *xuguBlockingDriver) Open(name string) (driver.Conn, error) {
|
|
return &xuguBlockingConn{}, nil
|
|
}
|
|
|
|
type xuguBlockingConn struct{}
|
|
|
|
func (c *xuguBlockingConn) Prepare(query string) (driver.Stmt, error) {
|
|
return &xuguBlockingStmt{}, nil
|
|
}
|
|
func (c *xuguBlockingConn) Close() error { return nil }
|
|
func (c *xuguBlockingConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
|
|
|
|
type xuguBlockingStmt struct{}
|
|
|
|
func (s *xuguBlockingStmt) Close() error { return nil }
|
|
func (s *xuguBlockingStmt) NumInput() int { return -1 }
|
|
func (s *xuguBlockingStmt) Exec(args []driver.Value) (driver.Result, error) {
|
|
<-xuguBlockingUnblock
|
|
return nil, errors.New("killed")
|
|
}
|
|
func (s *xuguBlockingStmt) Query(args []driver.Value) (driver.Rows, error) {
|
|
<-xuguBlockingUnblock
|
|
return nil, errors.New("killed")
|
|
}
|
|
|
|
type xuguFastDriver struct{}
|
|
|
|
func (d *xuguFastDriver) Open(name string) (driver.Conn, error) {
|
|
return &xuguFastConn{}, nil
|
|
}
|
|
|
|
type xuguFastConn struct{}
|
|
|
|
func (c *xuguFastConn) Prepare(query string) (driver.Stmt, error) {
|
|
return &xuguFastStmt{}, nil
|
|
}
|
|
func (c *xuguFastConn) Close() error { return nil }
|
|
func (c *xuguFastConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
|
|
|
|
type xuguFastStmt struct{}
|
|
|
|
func (s *xuguFastStmt) Close() error { return nil }
|
|
func (s *xuguFastStmt) NumInput() int { return -1 }
|
|
func (s *xuguFastStmt) Exec(args []driver.Value) (driver.Result, error) {
|
|
return driver.ResultNoRows, nil
|
|
}
|
|
func (s *xuguFastStmt) Query(args []driver.Value) (driver.Rows, error) {
|
|
return &xuguFastRows{}, nil
|
|
}
|
|
|
|
type xuguFastRows struct {
|
|
pos int
|
|
closed bool
|
|
}
|
|
|
|
func (r *xuguFastRows) Columns() []string { return []string{"id"} }
|
|
func (r *xuguFastRows) Close() error { r.closed = true; return nil }
|
|
func (r *xuguFastRows) Next(dest []driver.Value) error {
|
|
if r.pos >= 3 || r.closed {
|
|
return io.EOF
|
|
}
|
|
dest[0] = int64(r.pos + 1)
|
|
r.pos++
|
|
return nil
|
|
}
|
|
|
|
// -- timeout tests --
|
|
|
|
func TestXuguWatchdogFiresKillAndCancel(t *testing.T) {
|
|
s := newServer()
|
|
killCh := make(chan struct{})
|
|
s.killSession = func() { close(killCh) }
|
|
|
|
ctx, cancel := s.beginActiveOperationWithTimeout(0)
|
|
cancel() // clean up the initial call
|
|
|
|
ctx, cancel = s.beginActiveOperationWithTimeout(1)
|
|
defer func() {
|
|
s.activeCancelMu.Lock()
|
|
if s.activeTimer != nil {
|
|
s.activeTimer.Stop()
|
|
}
|
|
s.activeCancelMu.Unlock()
|
|
cancel()
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("watchdog timer did not fire within 2 seconds")
|
|
}
|
|
|
|
select {
|
|
case <-killCh:
|
|
default:
|
|
t.Fatal("killSession was not called when watchdog fired")
|
|
}
|
|
}
|
|
|
|
func TestXuguNoWatchdogWhenTimeoutZero(t *testing.T) {
|
|
s := newServer()
|
|
var killed bool
|
|
var killMu sync.Mutex
|
|
s.killSession = func() {
|
|
killMu.Lock()
|
|
killed = true
|
|
killMu.Unlock()
|
|
}
|
|
|
|
ctx, cancel := s.beginActiveOperationWithTimeout(0)
|
|
defer cancel()
|
|
|
|
s.activeCancelMu.Lock()
|
|
hasTimer := s.activeTimer != nil
|
|
timedOut := s.activeTimedOut
|
|
s.activeCancelMu.Unlock()
|
|
|
|
if hasTimer {
|
|
t.Fatal("timer should not be created when timeoutSecs=0")
|
|
}
|
|
if timedOut {
|
|
t.Fatal("activeTimedOut should be false when timeoutSecs=0")
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("context should not be cancelled when timeoutSecs=0")
|
|
default:
|
|
}
|
|
|
|
killMu.Lock()
|
|
if killed {
|
|
t.Fatal("killSession should not be called when timeoutSecs=0")
|
|
}
|
|
killMu.Unlock()
|
|
}
|
|
|
|
func TestXuguCursorSurvivesDeadlineWindow(t *testing.T) {
|
|
s := newServer()
|
|
var killed bool
|
|
var killMu sync.Mutex
|
|
s.killSession = func() {
|
|
killMu.Lock()
|
|
killed = true
|
|
killMu.Unlock()
|
|
}
|
|
|
|
db, err := sql.Open("xugu-test-fast", "dsn")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.db = db
|
|
s.cancelDB = db
|
|
|
|
rows, err := s.queryRowsWithTimeout("SELECT id FROM test", nil, 1)
|
|
if err != nil {
|
|
t.Fatalf("queryRowsWithTimeout failed: %v", err)
|
|
}
|
|
defer s.closeRows(rows)
|
|
|
|
s.activeCancelMu.Lock()
|
|
timerStopped := s.activeTimer == nil
|
|
s.activeCancelMu.Unlock()
|
|
if !timerStopped {
|
|
t.Fatal("timer should be stopped after QueryContext returns")
|
|
}
|
|
|
|
time.Sleep(1200 * time.Millisecond)
|
|
|
|
// Read all rows to verify cursor survived the deadline window.
|
|
cols, _ := rows.Columns()
|
|
values := make([]any, len(cols))
|
|
for i := range values {
|
|
values[i] = new(any)
|
|
}
|
|
rowCount := 0
|
|
for rows.Next() {
|
|
rowCount++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("cursor was killed by deadline: %v", err)
|
|
}
|
|
if rowCount != 3 {
|
|
t.Fatalf("expected 3 rows, got %d", rowCount)
|
|
}
|
|
|
|
killMu.Lock()
|
|
if killed {
|
|
t.Fatal("killSession should not be called when query completes normally")
|
|
}
|
|
killMu.Unlock()
|
|
}
|
|
|
|
func TestXuguWatchdogCallsKillOnBlockingQuery(t *testing.T) {
|
|
resetXuguBlockingDriver()
|
|
|
|
s := newServer()
|
|
killCh := make(chan struct{})
|
|
s.killSession = func() { close(killCh) }
|
|
|
|
db, err := sql.Open("xugu-test-blocking", "dsn")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.db = db
|
|
s.cancelDB = db
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := s.queryRowsWithTimeout("SELECT 1", nil, 1)
|
|
errCh <- err
|
|
}()
|
|
|
|
select {
|
|
case <-killCh:
|
|
// kill was called as expected
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("killSession was not called within timeout window")
|
|
}
|
|
|
|
// Unblock the fake driver so queryRowsWithTimeout can return.
|
|
close(xuguBlockingUnblock)
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err == nil {
|
|
t.Fatal("expected non-nil error after kill")
|
|
}
|
|
if !strings.Contains(err.Error(), "killed") && !strings.Contains(err.Error(), "timed out") {
|
|
t.Fatalf("expected killed or timeout error, got: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("query did not return after unblocking driver")
|
|
}
|
|
}
|