kioku-core-0.4.0.0: test/Kioku/WorkspaceSpec.hs
-- | The filesystem half of the memory-space partition.
--
-- The database stopped letting two spaces collide when their rows got a composite key. These
-- cases are about the other artifact: a Markdown mirror whose filename is derived from a scope
-- that two spaces are allowed to share.
--
-- The traversal cases are the ones worth reading twice. A 'MemorySpaceId' is validated for a
-- database column, not for a path — @..@ passes 'mkMemorySpaceId' — so nothing but the encoding
-- in "Kioku.Workspace" stands between a hostile space id and the rest of the disk.
module Kioku.WorkspaceSpec (tests) where
import Data.List (isInfixOf)
import Data.Text qualified as Text
import Kioku.Api.Access (MemorySpaceId, mkMemorySpaceId)
import Kioku.Workspace
( ArtifactMove (..),
MoveVerdict (..),
applyArtifactMigration,
legacyPersonaArtifactDir,
legacySceneArtifactDir,
personaArtifactDir,
planArtifactMigration,
sceneArtifactDir,
spaceArtifactRoot,
spaceDirectoryName,
)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath (isRelative, joinPath, splitDirectories, (</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Workspace artifact layout"
[ testCase "two spaces never share an artifact root" testDistinctRoots,
testCase "the same space always gets the same root" testStableRoot,
testCase "a case-only difference is still two roots" testCaseOnlyDifference,
testCase "no space id can escape .kioku/spaces" testNoTraversal,
testCase "a fresh workspace has nothing to migrate" testEmptyMigration,
testCase "the historical tree is planned, copied, and left in place" testMigrationCopies,
testCase "a second run is a no-op" testMigrationIdempotent,
testCase "a destination with different content is refused" testMigrationCollision,
testCase "a non-markdown file is not Kioku's to relocate" testMigrationIgnoresOtherFiles
]
-- * Layout
-- | Same namespace, same scope, same filename — different directory. Without this the two
-- spaces' mirrors are one file, and whichever regenerated last wins.
testDistinctRoots :: Assertion
testDistinctRoots =
assertBool
"two memory spaces resolved to the same artifact root"
(spaceArtifactRoot "/w" (spaceNamed "space_a") /= spaceArtifactRoot "/w" (spaceNamed "space_b"))
testStableRoot :: Assertion
testStableRoot =
assertEqual
"the artifact root must be a function of the space id alone"
(spaceArtifactRoot "/w" (spaceNamed "space_a"))
(spaceArtifactRoot "/w" (spaceNamed "space_a"))
-- | macOS and Windows fold case in path components, so a sanitised name alone would merge these
-- two spaces into one directory on the machines this is developed on. The digest is what keeps
-- them apart, and it is over the exact bytes.
testCaseOnlyDifference :: Assertion
testCaseOnlyDifference =
assertBool
"space_A and space_a resolved to the same artifact root"
(spaceDirectoryName (spaceNamed "space_A") /= spaceDirectoryName (spaceNamed "space_a"))
-- | Every one of these is a legal 'MemorySpaceId': 'mkMemorySpaceId' rejects @:@, @#@, @%@, @\/@,
-- whitespace and control characters, and nothing else. @..@ in particular would walk out of
-- @.kioku\/spaces@ if the id were used as a path component directly.
testNoTraversal :: Assertion
testNoTraversal =
mapM_ check ["..", ".", "...", "..-..", "a.b", "____"]
where
check raw = do
let space = spaceNamed (Text.pack raw)
name = Text.unpack (spaceDirectoryName space)
root = spaceArtifactRoot "workspace" space
assertBool
(raw <> " encoded to a directory name with a path separator: " <> name)
(length (splitDirectories name) == 1)
assertBool
(raw <> " encoded to a dot segment: " <> name)
(name /= "." && name /= "..")
assertBool
(raw <> " escaped .kioku/spaces: " <> root)
(joinPath ["workspace", ".kioku", "spaces"] `isInfixOf` root && isRelative root)
-- * Migration
testEmptyMigration :: Assertion
testEmptyMigration =
withSystemTempDirectory "kioku-workspace-empty" \workspace -> do
moves <- planArtifactMigration workspace legacyish
moves @?= []
-- | The plan names both trees, the apply copies them, and — the part that matters — the original
-- is still there afterwards. An operator who applies and then finds the new layout wrong must
-- still have something to fall back on.
testMigrationCopies :: Assertion
testMigrationCopies =
withSystemTempDirectory "kioku-workspace-copy" \workspace -> do
writeHistorical workspace "scenes" "web-abc.md" "scene body"
writeHistorical workspace "persona" "web-abc.md" "persona body"
planned <- planArtifactMigration workspace legacyish
map (.verdict) planned @?= [MoveReady, MoveReady]
assertEqual
"the plan must name both partitioned destinations, scenes first"
[ sceneArtifactDir workspace legacyish </> "web-abc.md",
personaArtifactDir workspace legacyish </> "web-abc.md"
]
(map (.destination) planned)
applyArtifactMigration planned
assertFileIs (sceneArtifactDir workspace legacyish </> "web-abc.md") "scene body"
assertFileIs (personaArtifactDir workspace legacyish </> "web-abc.md") "persona body"
assertFileIs (legacySceneArtifactDir workspace </> "web-abc.md") "scene body"
assertFileIs (legacyPersonaArtifactDir workspace </> "web-abc.md") "persona body"
testMigrationIdempotent :: Assertion
testMigrationIdempotent =
withSystemTempDirectory "kioku-workspace-idempotent" \workspace -> do
writeHistorical workspace "scenes" "web-abc.md" "scene body"
planArtifactMigration workspace legacyish >>= applyArtifactMigration
replanned <- planArtifactMigration workspace legacyish
map (.verdict) replanned @?= [MoveAlreadyMigrated]
-- Applying the second plan must still be safe, and must still leave the file alone.
applyArtifactMigration replanned
assertFileIs (sceneArtifactDir workspace legacyish </> "web-abc.md") "scene body"
-- | The partitioned file is the one the running system writes. A pre-partition snapshot with the
-- same name is older, so copying over it would replace current content with stale content.
testMigrationCollision :: Assertion
testMigrationCollision =
withSystemTempDirectory "kioku-workspace-collision" \workspace -> do
writeHistorical workspace "scenes" "web-abc.md" "the pre-partition snapshot"
createDirectoryIfMissing True (sceneArtifactDir workspace legacyish)
writeFile (sceneArtifactDir workspace legacyish </> "web-abc.md") "what the worker wrote today"
planned <- planArtifactMigration workspace legacyish
map (.verdict) planned @?= [MoveCollision]
applyArtifactMigration planned
assertFileIs (sceneArtifactDir workspace legacyish </> "web-abc.md") "what the worker wrote today"
-- | Only @.md@ files were ever Kioku's. An editor swap file or a README an operator dropped in
-- the directory is theirs, and relocating it would be a surprise.
testMigrationIgnoresOtherFiles :: Assertion
testMigrationIgnoresOtherFiles =
withSystemTempDirectory "kioku-workspace-other" \workspace -> do
writeHistorical workspace "scenes" "notes.txt" "not a mirror"
writeHistorical workspace "scenes" ".web-abc.md.swp" "not a mirror either"
moves <- planArtifactMigration workspace legacyish
moves @?= []
-- * Helpers
-- | Not 'legacyMemorySpaceId' itself, because these cases are about the layout rather than about
-- which space the CLI defaults to, and a fixture that happened to be the default would hide a
-- path built from a hard-coded constant.
legacyish :: MemorySpaceId
legacyish = spaceNamed "space_migrated"
writeHistorical :: FilePath -> FilePath -> FilePath -> String -> IO ()
writeHistorical workspace kind name body = do
let dir = workspace </> ".kioku" </> kind
createDirectoryIfMissing True dir
writeFile (dir </> name) body
assertFileIs :: FilePath -> String -> Assertion
assertFileIs path expected = do
exists <- doesFileExist path
assertBool ("expected a file at " <> path) exists
actual <- readFile path
assertEqual ("contents of " <> path) expected actual
spaceNamed :: Text.Text -> MemorySpaceId
spaceNamed = either (error . Text.unpack) id . mkMemorySpaceId