robust testing of the table definitions

This commit is contained in:
filipriec
2025-06-18 21:37:30 +02:00
parent f5fae98c69
commit 4843b0778c
4 changed files with 1043 additions and 0 deletions

View File

@@ -0,0 +1,502 @@
// ============================================================================
// Additional edgecase tests for PostTableDefinition
// ============================================================================
// 1) Fieldtype mapping for every predefined key, in various casing.
#[rstest]
#[tokio::test]
async fn test_field_type_mapping_various_casing(#[future] pool: PgPool) {
let pool = pool.await;
let cases = vec![
("text", "TEXT", "text"),
("TEXT", "TEXT", "text"),
("TeXt", "TEXT", "text"),
("string", "TEXT", "text"),
("boolean", "BOOLEAN", "boolean"),
("Boolean", "BOOLEAN", "boolean"),
("timestamp", "TIMESTAMPTZ", "timestamp with time zone"),
("time", "TIMESTAMPTZ", "timestamp with time zone"),
("money", "NUMERIC(14, 4)", "numeric"),
("integer", "INTEGER", "integer"),
("date", "DATE", "date"),
];
for (i, &(input, expected_sql, expected_db)) in cases.iter().enumerate() {
let tbl = format!("ftm_{}", i);
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: tbl.clone(),
columns: vec![ColumnDefinition {
name: "col".into(),
field_type: input.into(),
}],
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp.sql.contains(&format!("\"col\" {}", expected_sql)),
"fieldtype {:?} did not map to {} in `{}`",
input,
expected_sql,
resp.sql
);
assert_table_structure_is_correct(
&pool,
&tbl,
&[
("id", "bigint"),
("deleted", "boolean"),
("col", expected_db),
("created_at", "timestamp with time zone"),
],
)
.await;
}
}
// 3) Invalid index names must be rejected.
#[rstest]
#[tokio::test]
async fn test_fail_on_invalid_index_names(#[future] pool: PgPool) {
let pool = pool.await;
let bad_idxs = vec!["1col", "_col", "col-name"];
for idx in bad_idxs {
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "idx_bad".into(),
columns: vec![ColumnDefinition {
name: "good".into(),
field_type: "text".into(),
}],
indexes: vec![idx.into()],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err
.message()
.to_lowercase()
.contains("invalid index name"),
"{:?} yielded wrong message: {}",
idx,
err.message()
);
}
}
// 4) More invalidtablename cases: starts-with digit/underscore or sanitizes to empty.
#[rstest]
#[tokio::test]
async fn test_fail_on_more_invalid_table_names(#[future] pool: PgPool) {
let pool = pool.await;
let cases = vec![
("1tbl", "invalid table name"),
("_tbl", "invalid table name"),
("!@#$", "cannot be empty"),
("__", "cannot be empty"),
];
for (name, expected_msg) in cases {
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: name.into(),
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err.message().to_lowercase().contains(expected_msg),
"{:?} => {}",
name,
err.message()
);
}
}
// 5) Namesanitization: mixedcase table names and strip invalid characters.
#[rstest]
#[tokio::test]
async fn test_name_sanitization(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "My-Table!123".into(),
columns: vec![ColumnDefinition {
name: "User Name".into(),
field_type: "text".into(),
}],
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp.sql.contains("CREATE TABLE gen.\"mytable123\""),
"{:?}",
resp.sql
);
assert!(
resp.sql.contains("\"username\" TEXT"),
"{:?}",
resp.sql
);
assert_table_structure_is_correct(
&pool,
"mytable123",
&[
("id", "bigint"),
("deleted", "boolean"),
("username", "text"),
("created_at", "timestamp with time zone"),
],
)
.await;
}
// 6) Creating a table with no custom columns, indexes, or links → only system columns.
#[rstest]
#[tokio::test]
async fn test_create_minimal_table(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "minimal".into(),
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(resp.sql.contains("id BIGSERIAL PRIMARY KEY"));
assert!(resp.sql.contains("deleted BOOLEAN NOT NULL"));
assert!(resp.sql.contains("created_at TIMESTAMPTZ"));
assert_table_structure_is_correct(
&pool,
"minimal",
&[
("id", "bigint"),
("deleted", "boolean"),
("created_at", "timestamp with time zone"),
],
)
.await;
}
// 7) Required & optional links: NOT NULL vs NULL.
#[rstest]
#[tokio::test]
async fn test_nullable_and_multiple_links(#[future] pool_with_preexisting_table: PgPool) {
let pool = pool_with_preexisting_table.await;
// create a second linktarget
let sup = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "suppliers".into(),
columns: vec![ColumnDefinition {
name: "sup_name".into(),
field_type: "text".into(),
}],
indexes: vec!["sup_name".into()],
links: vec![],
};
post_table_definition(&pool, sup).await.unwrap();
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "orders_links".into(),
columns: vec![],
indexes: vec![],
links: vec![
TableLink {
linked_table_name: "customers".into(),
required: true,
},
TableLink {
linked_table_name: "suppliers".into(),
required: false,
},
],
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp
.sql
.contains("\"customers_id\" BIGINT NOT NULL"),
"{:?}",
resp.sql
);
assert!(
resp.sql.contains("\"suppliers_id\" BIGINT"),
"{:?}",
resp.sql
);
// DBlevel nullability for optional FK
let is_nullable: String = sqlx::query_scalar!(
"SELECT is_nullable \
FROM information_schema.columns \
WHERE table_schema='gen' \
AND table_name=$1 \
AND column_name='suppliers_id'",
"orders_links"
)
.fetch_one(&pool)
.await
.unwrap()
.unwrap();
assert_eq!(is_nullable, "YES");
}
// 8) Duplicate links in one request → Internal.
#[rstest]
#[tokio::test]
async fn test_fail_on_duplicate_links(#[future] pool_with_preexisting_table: PgPool) {
let pool = pool_with_preexisting_table.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "dup_links".into(),
columns: vec![],
indexes: vec![],
links: vec![
TableLink {
linked_table_name: "customers".into(),
required: true,
},
TableLink {
linked_table_name: "customers".into(),
required: false,
},
],
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::Internal);
}
// 9) Selfreferential FK: link child back to sameprofile parent.
#[rstest]
#[tokio::test]
async fn test_self_referential_link(#[future] pool: PgPool) {
let pool = pool.await;
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "selfref".into(),
..Default::default()
},
)
.await
.unwrap();
let resp = post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "selfref_child".into(),
links: vec![TableLink {
linked_table_name: "selfref".into(),
required: true,
}],
..Default::default()
},
)
.await
.unwrap();
assert!(
resp
.sql
.contains("\"selfref_id\" BIGINT NOT NULL REFERENCES gen.\"selfref\"(id)"),
"{:?}",
resp.sql
);
}
// 11) Crossprofile uniqueness & link isolation.
#[rstest]
#[tokio::test]
async fn test_cross_profile_uniqueness_and_link_isolation(#[future] pool: PgPool) {
let pool = pool.await;
// Profile A: foo
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "A".into(),
table_name: "foo".into(),
..Default::default()
},
)
.await
.unwrap();
// Profile B: foo, bar
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "B".into(),
table_name: "foo".into(),
..Default::default()
},
)
.await
.unwrap();
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "B".into(),
table_name: "bar".into(),
..Default::default()
},
)
.await
.unwrap();
// A linking to B.bar → NotFound
let err = post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "A".into(),
table_name: "linker".into(),
links: vec![TableLink {
linked_table_name: "bar".into(),
required: false,
}],
..Default::default()
},
)
.await
.unwrap_err();
assert_eq!(err.code(), Code::NotFound);
}
// 12) SQLinjection attempts are sanitized.
#[rstest]
#[tokio::test]
async fn test_sql_injection_sanitization(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "users; DROP TABLE users;".into(),
columns: vec![ColumnDefinition {
name: "col\"; DROP".into(),
field_type: "text".into(),
}],
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp
.sql
.contains("CREATE TABLE gen.\"usersdroptableusers\""),
"{:?}",
resp.sql
);
assert!(
resp.sql.contains("\"coldrop\" TEXT"),
"{:?}",
resp.sql
);
assert_table_structure_is_correct(
&pool,
"usersdroptableusers",
&[
("id", "bigint"),
("deleted", "boolean"),
("coldrop", "text"),
("created_at", "timestamp with time zone"),
],
)
.await;
}
// 13) Reservedcolumn shadowing: id, deleted, created_at cannot be userdefined.
#[rstest]
#[tokio::test]
async fn test_reserved_column_shadowing(#[future] pool: PgPool) {
let pool = pool.await;
for col in &["id", "deleted", "created_at"] {
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: format!("tbl_{}", col),
columns: vec![ColumnDefinition {
name: (*col).into(),
field_type: "text".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::Internal, "{:?}", col);
}
}
// 14) Identifierlength overflow (>63 chars) yields Internal.
#[rstest]
#[tokio::test]
async fn test_long_identifier_length(#[future] pool: PgPool) {
let pool = pool.await;
let long = "a".repeat(64);
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: long.clone(),
columns: vec![ColumnDefinition {
name: long.clone(),
field_type: "text".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::Internal);
}
// 15) Decimal precision overflow must be caught by our parser.
#[rstest]
#[tokio::test]
async fn test_decimal_precision_overflow(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "dp_overflow".into(),
columns: vec![ColumnDefinition {
name: "amount".into(),
field_type: "decimal(9999999999,1)".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err
.message()
.to_lowercase()
.contains("invalid precision"),
"{}",
err.message()
);
}
// 16) Repeated profile insertion only creates one profile row.
#[rstest]
#[tokio::test]
async fn test_repeated_profile_insertion(#[future] pool: PgPool) {
let pool = pool.await;
let prof = "repeat_prof";
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: prof.into(),
table_name: "t1".into(),
..Default::default()
},
)
.await
.unwrap();
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: prof.into(),
table_name: "t2".into(),
..Default::default()
},
)
.await
.unwrap();
let cnt: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM profiles WHERE name = $1",
prof
)
.fetch_one(&pool)
.await
.unwrap()
.unwrap();
assert_eq!(cnt, 1);
}