using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using ItWorx.KnowledgeHub.Application.Migration; using ItWorx.KnowledgeHub.Contracts.Attachments; using ItWorx.KnowledgeHub.Contracts.Automation; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.Contracts.Migration; using ItWorx.KnowledgeHub.Domain.Automation; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using Microsoft.Data.Sqlite; using Microsoft.Extensions.DependencyInjection; namespace ItWorx.KnowledgeHub.IntegrationTests.Migration; /// /// The LeafWiki filesystem-Markdown importer end to end (docs/17-leafwiki-migration.md, /// docs/27-acceptance-matrix.md M4-A04..A08): discovery/fingerprint, preflight without /// target mutation, hierarchy/link/attachment-preserving execution, rerun idempotency, /// rollback that preserves user edits, and source immutability. Every fixture here is /// synthetic, written fresh per test into an isolated temp directory — never a real /// LeafWiki snapshot (docs/09-data-safety, docs/22-testing-strategy.md). /// [Collection(PostgresTestGroup.Name)] public sealed class LeafWikiImportTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { private string CreateSourceDirectory() { var path = Path.Combine(Factory.ImportsDirectory, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; } private string RelativeToImportsRoot(string absolutePath) => Path.GetRelativePath(Factory.ImportsDirectory, absolutePath).Replace('\\', '/'); private static void WriteFile(string root, string relativePath, string content) { var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(full)!); File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } [Fact] public async Task Preflight_discovers_nested_pages_and_never_mutates_the_target() { var source = CreateSourceDirectory(); WriteFile(source, "index.md", "---\ntitle: Startpagina\n---\nWelkom."); WriteFile(source, "school/index.md", "# School\nOverzicht van schoolinhoud."); WriteFile(source, "school/wiskunde.md", "# Wiskunde\nFormules en oefeningen."); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var beforeRootsResponse = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(beforeRootsResponse); var beforeCount = beforeRootsResponse.Items.Count; var preflightResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"test-{Guid.NewGuid():N}")); Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode); var plan = await preflightResponse.Content.ReadFromJsonAsync(); Assert.NotNull(plan); Assert.Equal("Success", plan.Status); Assert.Equal("filesystem-markdown", plan.AdapterName); Assert.Equal(3, plan.PlannedCount); Assert.Contains(plan.Items, item => item.ProposedTitle == "Startpagina" && item.ProposedParentSourceIdentity == null); Assert.Contains(plan.Items, item => item.ProposedTitle == "School" && item.ProposedParentSourceIdentity == null); Assert.Contains(plan.Items, item => item.ProposedTitle == "Wiskunde" && item.ProposedParentSourceIdentity == "school/index.md"); // No target mutation during preflight. var afterRootsResponse = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(afterRootsResponse); Assert.Equal(beforeCount, afterRootsResponse.Items.Count); } [Fact] public async Task A_source_root_absolute_asset_link_is_recognized_and_imported_not_reported_missing() { // A real LeafWiki export commonly writes asset links as source-root-absolute // ("/assets//file.png"), unlike every other fixture in this file (and // CutoverRehearsalTests' own comment on this exact point), which only ever // uses an already-relative reference. Found against a real export, not a // hypothetical — this is the real regression test for that fix. byte[] pngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03]; var source = CreateSourceDirectory(); WriteFile(source, "index.md", "# Netwerkdiagram\n![Diagram](/assets/abc123/diagram.png)\n"); var assetPath = Path.Combine(source, "assets", "abc123", "diagram.png"); Directory.CreateDirectory(Path.GetDirectoryName(assetPath)!); File.WriteAllBytes(assetPath, pngBytes); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var preflightResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"asset-{Guid.NewGuid():N}")); var plan = await preflightResponse.Content.ReadFromJsonAsync(); Assert.NotNull(plan); Assert.DoesNotContain(plan.Warnings, warning => warning.Contains("niet gevonden", StringComparison.Ordinal)); var executeResponse = await SetupHelper.PostWithCsrfAsync( client, $"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}", (object?)null); Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode); var executed = await executeResponse.Content.ReadFromJsonAsync(); Assert.NotNull(executed); Assert.Empty(executed.Diagnostics); var roots = await client.GetFromJsonAsync("/api/v1/pages/roots"); var page = Assert.Single(roots!.Items, item => item.Title == "Netwerkdiagram"); var attachments = await client.GetFromJsonAsync($"/api/v1/pages/{page.Id}/attachments"); Assert.NotNull(attachments); Assert.Single(attachments.Items); } [Fact] public async Task Execute_creates_the_hierarchy_resolves_wiki_links_and_leaves_unresolved_links_as_reported() { var source = CreateSourceDirectory(); WriteFile(source, "school/index.md", "# School\nZie [[Wiskunde]] en [[Onbestaande pagina]]."); WriteFile(source, "school/wiskunde.md", "# Wiskunde\nFormules."); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var preflightResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"test-{Guid.NewGuid():N}")); var plan = await preflightResponse.Content.ReadFromJsonAsync(); Assert.NotNull(plan); Assert.NotNull(plan.JobId); var executeResponse = await SetupHelper.PostWithCsrfAsync( client, $"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}", (object?)null); Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode); var executeResult = await executeResponse.Content.ReadFromJsonAsync(); Assert.NotNull(executeResult); Assert.Equal(2, executeResult.Imported); Assert.Equal(0, executeResult.Failed); Assert.True(executeResult.SourceUnchangedAfterExecution); var roots = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(roots); var schoolPage = Assert.Single(roots.Items, item => item.Title == "School"); var schoolDetail = await client.GetFromJsonAsync($"/api/v1/pages/{schoolPage.Id}"); Assert.NotNull(schoolDetail); // A resolved internal link renders as a real anchor; the unresolved one stays // literal bracket text (docs/11-markdown-and-linking.md, proven again here on // imported content). Assert.Contains("($"/api/v1/pages/{schoolPage.Id}/children"); Assert.NotNull(children); Assert.Contains(children.Items, item => item.Title == "Wiskunde"); } [Fact] public async Task Rerunning_the_same_source_is_idempotent_and_skips_unchanged_items() { var source = CreateSourceDirectory(); WriteFile(source, "pagina.md", "# Enkele pagina\nInhoud."); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); async Task RunOnceAsync(string idempotencyKey) { var preflight = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), idempotencyKey)); var plan = await preflight.Content.ReadFromJsonAsync(); Assert.NotNull(plan); var execute = await SetupHelper.PostWithCsrfAsync( client, $"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}", (object?)null); var result = await execute.Content.ReadFromJsonAsync(); Assert.NotNull(result); return result; } var first = await RunOnceAsync($"run1-{Guid.NewGuid():N}"); Assert.Equal(1, first.Imported); Assert.Equal(0, first.Skipped); var second = await RunOnceAsync($"run2-{Guid.NewGuid():N}"); Assert.Equal(0, second.Imported); Assert.Equal(1, second.Skipped); var roots = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(roots); Assert.Single(roots.Items, item => item.Title == "Enkele pagina"); } [Fact] public async Task Rollback_removes_an_unedited_imported_page_but_preserves_one_the_user_edited_afterward() { var source = CreateSourceDirectory(); WriteFile(source, "ongewijzigd.md", "# Ongewijzigd\nBlijft zoals geïmporteerd."); WriteFile(source, "bewerkt.md", "# Bewerkt\nWordt na import aangepast."); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var preflight = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"rb-{Guid.NewGuid():N}")); var plan = await preflight.Content.ReadFromJsonAsync(); Assert.NotNull(plan); await SetupHelper.PostWithCsrfAsync( client, $"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}", (object?)null); var roots = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(roots); var editedPage = Assert.Single(roots.Items, item => item.Title == "Bewerkt"); var editedDetail = await client.GetFromJsonAsync($"/api/v1/pages/{editedPage.Id}"); Assert.NotNull(editedDetail); // The user edits the imported page before the rollback happens. await SetupHelper.PutWithCsrfAsync( client, $"/api/v1/pages/{editedPage.Id}", new UpdatePageRequest("Bewerkt door gebruiker", editedDetail.Markdown, "WikiPage", "Active", null), editedDetail.ETag); var rollbackResponse = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/admin/imports/{plan.JobId}/rollback", (object?)null); Assert.Equal(HttpStatusCode.OK, rollbackResponse.StatusCode); var rollback = await rollbackResponse.Content.ReadFromJsonAsync(); Assert.NotNull(rollback); Assert.Equal(1, rollback.RemovedPages); Assert.Equal(1, rollback.PreservedPages); var rootsAfterRollback = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(rootsAfterRollback); Assert.DoesNotContain(rootsAfterRollback.Items, item => item.Title == "Ongewijzigd"); Assert.Contains(rootsAfterRollback.Items, item => item.Title == "Bewerkt door gebruiker"); } [Fact] public async Task A_symlink_escaping_the_source_root_is_recorded_but_never_followed() { var source = CreateSourceDirectory(); var outsideTarget = Path.Combine(Path.GetTempPath(), "ikh-outside-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(outsideTarget); File.WriteAllText(Path.Combine(outsideTarget, "geheim.md"), "# Geheim\nMag niet gelezen worden."); WriteFile(source, "normale-pagina.md", "# Normale pagina\nInhoud."); try { File.CreateSymbolicLink(Path.Combine(source, "ontsnapping.md"), Path.Combine(outsideTarget, "geheim.md")); } catch (IOException) { // Symlink creation can require elevated privileges on some Windows // configurations; skip this specific assertion rather than fail the whole // suite for an environment limitation unrelated to the importer's logic. return; } using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var preflight = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"sym-{Guid.NewGuid():N}")); var plan = await preflight.Content.ReadFromJsonAsync(); Assert.NotNull(plan); // The symlink is not itself a Markdown source item, so it's excluded from the // plan entirely — the reader never opens a symlink target at all (recorded, // never followed), so the outside file's content cannot appear anywhere here. Assert.DoesNotContain(plan.Items, item => item.SourceIdentity.Contains("ontsnapping", StringComparison.Ordinal)); Assert.Single(plan.Items, item => item.SourceIdentity == "normale-pagina.md"); } [Fact] public async Task Preflight_refuses_a_linked_source_root_that_points_outside_imports() { Directory.CreateDirectory(Factory.ImportsDirectory); var outsideTarget = Path.Combine(Path.GetTempPath(), "ikh-outside-root-" + Guid.NewGuid().ToString("N")); var linkedSource = Path.Combine(Factory.ImportsDirectory, "linked-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(outsideTarget); WriteFile(outsideTarget, "outside-secret.md", "# Buiten de imports-map\nDeze bron mag niet worden ontdekt."); var outsideFile = Path.Combine(outsideTarget, "outside-secret.md"); var originalBytes = File.ReadAllBytes(outsideFile); try { Assert.True( TryCreateDirectoryLink(linkedSource, outsideTarget), "The test host must support a directory symlink or junction to exercise the source-root boundary."); Assert.NotNull(new DirectoryInfo(linkedSource).LinkTarget); Assert.True((File.GetAttributes(linkedSource) & FileAttributes.ReparsePoint) != 0); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var preflight = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(linkedSource), $"linked-root-{Guid.NewGuid():N}")); Assert.Equal(HttpStatusCode.UnprocessableEntity, preflight.StatusCode); var responseBody = await preflight.Content.ReadAsStringAsync(); Assert.Contains("import-source-invalid", responseBody, StringComparison.Ordinal); Assert.DoesNotContain("Deze bron mag niet worden ontdekt", responseBody, StringComparison.Ordinal); Assert.Equal(originalBytes, File.ReadAllBytes(outsideFile)); var reader = Factory.Services.GetRequiredService(); await Assert.ThrowsAsync( () => reader.OpenFileAsync(linkedSource, "outside-secret.md", CancellationToken.None)); Assert.Equal(originalBytes, File.ReadAllBytes(outsideFile)); } finally { if (Directory.Exists(linkedSource)) { Directory.Delete(linkedSource); } if (Directory.Exists(outsideTarget)) { File.Delete(outsideFile); Directory.Delete(outsideTarget); } } } private static bool TryCreateDirectoryLink(string linkPath, string targetPath) { if (!OperatingSystem.IsWindows()) { try { Directory.CreateSymbolicLink(linkPath, targetPath); return true; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) { return false; } } try { var startInfo = new ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true }; startInfo.ArgumentList.Add("/d"); startInfo.ArgumentList.Add("/c"); startInfo.ArgumentList.Add("mklink"); startInfo.ArgumentList.Add("/J"); startInfo.ArgumentList.Add(linkPath); startInfo.ArgumentList.Add(targetPath); using var process = Process.Start(startInfo); if (process is null) { return false; } process.WaitForExit(); return process.ExitCode == 0; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) { return false; } } [Fact] public async Task A_synthetic_sqlite_source_with_the_recognized_schema_is_imported_via_the_sqlite_adapter() { var source = CreateSourceDirectory(); var databasePath = Path.Combine(source, "leafwiki.sqlite"); CreateSyntheticSqliteFixture(databasePath); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var preflight = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"sqlite-{Guid.NewGuid():N}")); var plan = await preflight.Content.ReadFromJsonAsync(); Assert.NotNull(plan); Assert.Equal("synthetic-sqlite-v1", plan.AdapterName); Assert.Equal(2, plan.PlannedCount); var execute = await SetupHelper.PostWithCsrfAsync( client, $"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}", (object?)null); var result = await execute.Content.ReadFromJsonAsync(); Assert.NotNull(result); Assert.Equal(2, result.Imported); var roots = await client.GetFromJsonAsync("/api/v1/pages/roots"); Assert.NotNull(roots); Assert.Contains(roots.Items, item => item.Title == "SQLite-hoofdpagina"); } [Fact] public async Task An_unrecognized_sqlite_schema_is_a_preflight_blocker_not_a_guessed_mapping() { var source = CreateSourceDirectory(); var databasePath = Path.Combine(source, "unknown.sqlite"); CreateUnknownSchemaSqliteFixture(databasePath); using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var preflight = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"unknown-{Guid.NewGuid():N}")); Assert.Equal(HttpStatusCode.UnprocessableEntity, preflight.StatusCode); } [Fact] public async Task Only_an_administrator_can_run_a_preflight() { using var ownerClient = Factory.CreateClient(); await SetupHelper.LoginAsync(ownerClient, OwnerEmail, OwnerPassword); var editorEmail = $"redacteur-import-{Guid.NewGuid():N}@itworx.test"; const string editorPassword = "correct-horse-battery-staple"; await SetupHelper.PostWithCsrfAsync( ownerClient, "/api/v1/admin/users", new ItWorx.KnowledgeHub.Contracts.Admin.CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor")); using var editorClient = Factory.CreateClient(); await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword); var response = await SetupHelper.PostWithCsrfAsync( editorClient, "/api/v1/admin/imports/preflight", new PreflightImportRequest(".", $"forbidden-{Guid.NewGuid():N}")); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task A_bearer_token_with_imports_scopes_can_preflight_and_execute_a_real_import() { var source = CreateSourceDirectory(); WriteFile(source, "index.md", "# Startpagina\nWelkom."); using var adminClient = Factory.CreateClient(); await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest("Migratie-bot", "Testaccount.", [Scopes.ImportsExecute, Scopes.ImportsRead])); Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); var created = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(created); using var bearerClient = Factory.CreateClient(); bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken); var preflightResponse = await bearerClient.PostAsJsonAsync( "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"bearer-import-{Guid.NewGuid():N}")); Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode); var plan = await preflightResponse.Content.ReadFromJsonAsync(); Assert.NotNull(plan); Assert.NotNull(plan.JobId); var jobResponse = await bearerClient.GetAsync($"/api/v1/admin/imports/{plan.JobId}"); Assert.Equal(HttpStatusCode.OK, jobResponse.StatusCode); var executeResponse = await bearerClient.PostAsync( $"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}", content: null); Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode); var executed = await executeResponse.Content.ReadFromJsonAsync(); Assert.NotNull(executed); Assert.True(executed.Imported > 0); // imports:execute/imports:read never implicitly grant pages:read — scopes are // narrow and additive, never bundled (M6-A02), same property already proven // for pages:write/projects:write in ServiceAccountTests.cs. var pagesResponse = await bearerClient.GetAsync("/api/v1/pages/roots"); Assert.Equal(HttpStatusCode.Forbidden, pagesResponse.StatusCode); } [Fact] public async Task A_bearer_token_without_imports_scopes_cannot_run_a_preflight_vertical_privilege_escalation() { using var adminClient = Factory.CreateClient(); await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest("Read-only-bot", "Testaccount.", [Scopes.PagesRead])); var created = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(created); using var bearerClient = Factory.CreateClient(); bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken); var response = await bearerClient.PostAsJsonAsync( "/api/v1/admin/imports/preflight", new PreflightImportRequest(".", $"escalation-{Guid.NewGuid():N}")); Assert.True( response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized, $"Expected the escalation attempt to be denied, but got {response.StatusCode}."); } private static void CreateSyntheticSqliteFixture(string path) { using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = """ CREATE TABLE leafwiki_meta (key TEXT PRIMARY KEY, value TEXT); INSERT INTO leafwiki_meta (key, value) VALUES ('schema', 'itworx-synthetic-leafwiki-v1'); CREATE TABLE pages ( id INTEGER PRIMARY KEY, parent_id INTEGER NULL REFERENCES pages(id), title TEXT NOT NULL, body_markdown TEXT NOT NULL, created_at TEXT NOT NULL ); INSERT INTO pages (id, parent_id, title, body_markdown, created_at) VALUES (1, NULL, 'SQLite-hoofdpagina', '# Hoofdpagina', '2020-01-01'); INSERT INTO pages (id, parent_id, title, body_markdown, created_at) VALUES (2, 1, 'SQLite-subpagina', '# Subpagina', '2020-01-02'); """; command.ExecuteNonQuery(); } private static void CreateUnknownSchemaSqliteFixture(string path) { using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = "CREATE TABLE articles (id INTEGER PRIMARY KEY, headline TEXT);"; command.ExecuteNonQuery(); } }